Merge upstream master and move the reveal docs into docs/

Upstream moved the documentation sources from doc/ to docs/ (#7584), so the
page this branch adds now belongs under docs/doxygen/pages/user_documentation/
gameplay/. The index.md edit auto-merged onto the moved file, but the new page
stayed at the old path, and GitHub's merge engine reported a conflict in
index.md because the file it edits was deleted upstream.

No source changes: the merge is upstream/master as-is plus the doc relocation.
This commit is contained in:
Lukas Brübach 2026-11-08 10:02:49 +01:00
commit b64e168026
179 changed files with 4162 additions and 832 deletions

View file

@ -1,15 +1,16 @@
#include "update_downloader.h"
#include <QByteArray>
#include <QChar>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QIODevice>
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#include <QSaveFile>
#include <QTemporaryDir>
#include <QUrl>
#include <QVariant>
#include <libcockatrice/network/network_manager.h>
#include <utility>
UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr)
{
@ -23,48 +24,116 @@ void UpdateDownloader::beginDownload(QUrl downloadUrl)
originalUrl = downloadUrl;
}
if (auto *old = std::exchange(response, nullptr)) {
old->abort();
old->deleteLater();
}
errorReported = false;
cancelRequested = false;
// The transfer timeout comes from the manager (NetworkManagerFactory).
response = netMan->get(QNetworkRequest(downloadUrl));
connect(response, &QNetworkReply::finished, this, &UpdateDownloader::fileFinished);
connect(response, &QNetworkReply::errorOccurred, this, &UpdateDownloader::downloadError);
connect(response, &QNetworkReply::downloadProgress, this, &UpdateDownloader::downloadProgress);
connect(this, &UpdateDownloader::stopDownload, response, &QNetworkReply::abort);
connect(this, &UpdateDownloader::stopDownload, this, &UpdateDownloader::cancelDownload);
}
void UpdateDownloader::downloadError(QNetworkReply::NetworkError)
void UpdateDownloader::downloadError(QNetworkReply::NetworkError networkError)
{
if (response == nullptr) {
// A reply that was superseded by beginDownload() can still deliver a
// queued error; it must not be reported against the current reply.
if (response == nullptr || sender() != response || errorReported) {
return;
}
emit error(response->errorString().toUtf8());
// A transfer timeout aborts the reply, so it arrives as OperationCanceledError.
// Only an abort the user asked for is suppressed; every other cancel is a
// stalled transfer and has to be reported.
if (networkError == QNetworkReply::OperationCanceledError) {
if (cancelRequested) {
return;
}
errorReported = true;
emit error(tr("The download timed out. Check your connection and try again."));
return;
}
errorReported = true;
emit error(response->errorString());
}
void UpdateDownloader::cancelDownload()
{
cancelRequested = true;
if (response != nullptr) {
response->abort();
}
}
void UpdateDownloader::fileFinished()
{
// If we finished but there's a redirect, follow it
QVariant redirect = response->attribute(QNetworkRequest::RedirectionTargetAttribute);
if (!redirect.isNull()) {
beginDownload(redirect.toUrl());
// An aborted, superseded reply also emits finished(). Only the reply
// currently stored in response may be consumed.
if (response == nullptr || sender() != response) {
return;
}
// Handle any errors we had
if (response->error()) {
emit error(response->errorString());
// errorOccurred normally reports this first; keep the check for errors that
// only surface when the reply finishes.
if (response->error() != QNetworkReply::NoError) {
downloadError(response->error());
response->deleteLater();
response = nullptr;
return;
}
// Work out the file name of the download
QString fileName = QDir::temp().path() + QDir::separator() + originalUrl.toString().section('/', -1);
// Give the update its own private directory. The name is unpredictable, so a
// local user cannot pre-create or symlink the path, and the original file
// name (including the installer suffix) is preserved inside it.
QTemporaryDir downloadDir(QDir::temp().filePath(QStringLiteral("cockatrice-update-XXXXXX")));
if (!downloadDir.isValid()) {
emit error(tr("Could not create a temporary directory for the download."));
response->deleteLater();
response = nullptr;
return;
}
downloadDir.setAutoRemove(false);
// Save the build in a temporary directory
QFile file(fileName);
const QString remoteName = QFileInfo(originalUrl.path()).fileName();
const QString fileName = downloadDir.filePath(remoteName.isEmpty() ? QStringLiteral("update") : remoteName);
QSaveFile file(fileName);
if (!file.open(QIODevice::WriteOnly)) {
emit error(tr("Could not open the file for reading."));
downloadDir.remove();
emit error(tr("Could not open the file for writing: %1").arg(file.errorString()));
response->deleteLater();
response = nullptr;
return;
}
file.write(response->readAll());
file.close();
const QByteArray data = response->readAll();
if (file.write(data) != data.size()) {
file.cancelWriting();
downloadDir.remove();
emit error(tr("Could not write the downloaded file to disk."));
response->deleteLater();
response = nullptr;
return;
}
if (!file.commit()) {
downloadDir.remove();
emit error(tr("Could not save the downloaded file: %1").arg(file.errorString()));
response->deleteLater();
response = nullptr;
return;
}
response->deleteLater();
response = nullptr;
// Emit the success signal with a URL to the download file
emit downloadSuccessful(QUrl::fromLocalFile(fileName));

View file

@ -32,10 +32,13 @@ private:
QUrl originalUrl;
QNetworkAccessManager *netMan;
QNetworkReply *response;
bool errorReported = false;
bool cancelRequested = false;
private slots:
void fileFinished();
void downloadProgress(qint64 bytesRead, qint64 totalBytes);
void downloadError(QNetworkReply::NetworkError);
void cancelDownload();
};
#endif // COCKATRICE_UPDATEDOWNLOADER_H

View file

@ -88,7 +88,7 @@ void ArrowItem::onTargetDestroyed()
// Drag and attach arrows are never inserted into the arrow registry and
// have no server-side counterpart, so no deletion event can clean them
// up. Delete them locally when either endpoint is destroyed.
delArrow();
cancel();
return;
}
@ -103,6 +103,12 @@ void ArrowItem::delArrow()
deleteLater();
}
void ArrowItem::cancel()
{
ungrabMouse();
delArrow();
}
void ArrowItem::updatePath()
{
if (!targetItem) {
@ -350,6 +356,17 @@ void ArrowDragItem::addChildArrow(ArrowDragItem *child)
childArrows.append(child);
}
void ArrowDragItem::cancel()
{
for (const auto &child : childArrows) {
if (child) {
child->cancel();
}
}
childArrows.clear();
ArrowItem::cancel();
}
void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
{
if (targetLocked || !startItem) {
@ -393,8 +410,10 @@ void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
targetItem ? updatePath() : updatePath(endPos);
update();
for (auto *child : childArrows) {
child->mouseMoveEvent(event);
for (const auto &child : childArrows) {
if (child) {
child->mouseMoveEvent(event);
}
}
}
@ -403,10 +422,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
if (!startItem) {
// The source card was destroyed while the arrow was being drawn.
// Clean up the arrow and its children instead of leaking them.
delArrow();
for (auto *child : childArrows) {
child->mouseReleaseEvent(event);
}
cancel();
return;
}
@ -415,7 +431,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
// For now, we can safely assume that the start item is always a card.
// The target item can be a player as well.
if (!startCard) {
delArrow();
cancel();
return;
}
@ -435,7 +451,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
} else if (auto *targetPlayer = qgraphicsitem_cast<PlayerTarget *>(targetItem)) {
arrow.targetPlayerId = targetPlayer->getOwner()->getPlayerInfo()->getId();
} else {
delArrow();
cancel();
return;
}
@ -448,9 +464,12 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
}
delArrow();
for (auto *child : childArrows) {
child->mouseReleaseEvent(event);
for (const auto &child : childArrows) {
if (child) {
child->mouseReleaseEvent(event);
}
}
childArrows.clear();
}
// ArrowAttachItem
@ -471,6 +490,17 @@ void ArrowAttachItem::addChildArrow(ArrowAttachItem *child)
childArrows.append(child);
}
void ArrowAttachItem::cancel()
{
for (const auto &child : childArrows) {
if (child) {
child->cancel();
}
}
childArrows.clear();
ArrowItem::cancel();
}
void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
{
if (targetLocked || !startItem) {
@ -509,8 +539,10 @@ void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event)
targetItem ? updatePath() : updatePath(endPos);
update();
for (auto *child : childArrows) {
child->mouseMoveEvent(event);
for (const auto &child : childArrows) {
if (child) {
child->mouseMoveEvent(event);
}
}
}
@ -519,17 +551,16 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
if (!startItem) {
// The source card was destroyed while the arrow was being drawn.
// Clean up the arrow and its children instead of leaking them.
delArrow();
for (auto *child : childArrows) {
child->mouseReleaseEvent(event);
}
cancel();
return;
}
// Attaching could move startItem under the current cursor position, causing all children to retarget to it right
// before they are processed. Prevent that.
for (auto *child : childArrows) {
child->setTargetLocked(true);
for (const auto &child : childArrows) {
if (child) {
child->setTargetLocked(true);
}
}
if (targetItem && targetItem != startItem) {
@ -541,7 +572,10 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
}
delArrow();
for (auto *child : childArrows) {
child->mouseReleaseEvent(event);
for (const auto &child : childArrows) {
if (child) {
child->mouseReleaseEvent(event);
}
}
childArrows.clear();
}

View file

@ -70,6 +70,8 @@ public:
void onTargetDestroyed();
void delArrow();
/// Abandons an in-progress drag arrow, discarding any child arrows with it.
virtual void cancel();
void updatePath();
void updatePath(const QPointF &endPoint);
void startDrawAnimation();
@ -112,12 +114,13 @@ class ArrowDragItem : public ArrowItem
private:
PlayerLogic *player;
int deleteInPhase;
QList<ArrowDragItem *> childArrows;
QList<QPointer<ArrowDragItem>> childArrows;
QMetaObject::Connection positionConnection;
public:
ArrowDragItem(PlayerLogic *_owner, ArrowTarget *_startItem, const QColor &_color, int _deleteInPhase);
void addChildArrow(ArrowDragItem *child);
void cancel() override;
protected:
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;
@ -129,12 +132,13 @@ class ArrowAttachItem : public ArrowItem
Q_OBJECT
private:
PlayerLogic *player;
QList<ArrowAttachItem *> childArrows;
QList<QPointer<ArrowAttachItem>> childArrows;
QMetaObject::Connection positionConnection;
public:
explicit ArrowAttachItem(ArrowTarget *_startItem);
void addChildArrow(ArrowAttachItem *child);
void cancel() override;
protected:
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;

View file

@ -294,6 +294,13 @@ void CardItem::drawArrow(const QColor &arrowColor)
return;
}
// A held shortcut repeats the action before the previous arrow is placed.
// That arrow has lost the mouse grab to the new one, so it would never
// receive the release that deletes it; discard it (and its children).
if (pendingArrow) {
pendingArrow->cancel();
}
auto *game = owner->getGame();
PlayerLogic *arrowOwner = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer());
int phase = 0; // 0 means to not set the phase
@ -303,6 +310,7 @@ void CardItem::drawArrow(const QColor &arrowColor)
}
ArrowDragItem *arrow = new ArrowDragItem(arrowOwner, this, arrowColor, phase);
scene()->addItem(arrow);
pendingArrow = arrow;
arrow->grabMouse();
for (const auto &item : scene()->selectedItems()) {
@ -326,8 +334,14 @@ void CardItem::drawAttachArrow()
return;
}
// See drawArrow(): a repeated trigger must not orphan the previous arrow.
if (pendingArrow) {
pendingArrow->cancel();
}
auto *arrow = new ArrowAttachItem(this);
scene()->addItem(arrow);
pendingArrow = arrow;
arrow->grabMouse();
for (const auto &item : scene()->selectedItems()) {

View file

@ -13,11 +13,13 @@
#include <QList>
#include <QPoint>
#include <QPointF>
#include <QPointer>
#include <QString>
#include <QVariantList>
#include <libcockatrice/game/board/card_state.h>
#include <qtmetamacros.h>
class ArrowItem;
class CardDragItem;
class ServerInfo_Card;
class PlayerLogic;
@ -34,6 +36,7 @@ class CardItem : public AbstractCardItem
Q_OBJECT
private:
CardDragItem *dragItem;
QPointer<ArrowItem> pendingArrow;
void prepareDelete();
void handleClickedToPlay(bool shiftHeld);

View file

@ -64,13 +64,13 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position
QString zoneName = zone->getName();
if (zoneName == ZoneNames::TABLE) {
fromStr = tr(" from play");
fromStr = tr("from play");
} else if (zoneName == ZoneNames::GRAVE) {
fromStr = tr(" from their graveyard");
fromStr = tr("from their graveyard");
} else if (zoneName == ZoneNames::EXILE) {
fromStr = tr(" from exile");
fromStr = tr("from exile");
} else if (zoneName == ZoneNames::HAND) {
fromStr = tr(" from their hand");
fromStr = tr("from their hand");
} else if (zoneName == ZoneNames::DECK) {
if (position == 0) {
if (cardName.isEmpty()) {
@ -82,9 +82,9 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position
cardNameContainsStartZone = true;
} else {
if (ownerChange) {
fromStr = tr(" from the top of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName());
fromStr = tr("from the top of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName());
} else {
fromStr = tr(" from the top of their library");
fromStr = tr("from the top of their library");
}
}
} else if (position == zone->getCards().size()) {
@ -97,29 +97,34 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position
cardNameContainsStartZone = true;
} else {
if (ownerChange) {
fromStr = tr(" from the bottom of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName());
fromStr = tr("from the bottom of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName());
} else {
fromStr = tr(" from the bottom of their library");
fromStr = tr("from the bottom of their library");
}
}
} else {
if (ownerChange) {
fromStr = tr(" from %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName());
fromStr = tr("from %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName());
} else {
fromStr = tr(" from their library");
fromStr = tr("from their library");
}
}
} else if (zoneName == ZoneNames::SIDEBOARD) {
fromStr = tr(" from sideboard");
fromStr = tr("from sideboard");
} else if (zoneName == ZoneNames::STACK) {
fromStr = tr(" from the stack");
fromStr = tr("from the stack");
} else {
fromStr = tr(" from custom zone '%1'").arg(zoneName);
fromStr = tr("from custom zone '%1'").arg(zoneName);
}
if (!cardNameContainsStartZone) {
cardName.clear();
}
// The separator space is added here rather than stored in the tr() string so
// translators never have to preserve an invisible leading blank.
if (!fromStr.isEmpty()) {
fromStr.prepend(' ');
}
return {cardName, fromStr};
}

View file

@ -123,7 +123,7 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, CardItem *_card, bool _shortcuts
aRevealToAll = new QAction(this);
mCardCounters = new QMenu;
mCardCounters = new QMenu(this);
// Card counters
for (int i = 0; i < 6; ++i) {
@ -225,7 +225,7 @@ void CardMenu::createTableMenu(bool canModifyCard)
}
addSeparator();
addAction(aClone);
addMenu(new MoveMenu(player));
addMenu(new MoveMenu(player, this));
addSeparator();
addAction(aAttach);
if (card->getAttachedTo()) {
@ -233,7 +233,7 @@ void CardMenu::createTableMenu(bool canModifyCard)
}
addAction(aDrawArrow);
addSeparator();
addMenu(new PtMenu(player));
addMenu(new PtMenu(player, this));
addAction(aSetAnnotation);
addSeparator();
addAction(aReduceLifeByPower);
@ -275,7 +275,7 @@ void CardMenu::createStackMenu(bool canModifyCard)
addAction(aPlayFacedown);
addSeparator();
addAction(aClone);
addMenu(new MoveMenu(player));
addMenu(new MoveMenu(player, this));
addSeparator();
addAction(aAttach);
addAction(aDrawArrow);
@ -304,7 +304,7 @@ void CardMenu::createGraveyardOrExileMenu(bool canModifyCard)
addAction(aPlayFacedown);
addSeparator();
addAction(aClone);
addMenu(new MoveMenu(player));
addMenu(new MoveMenu(player, this));
addSeparator();
addAction(aAttach);
addAction(aDrawArrow);
@ -342,7 +342,7 @@ void CardMenu::createHandOrCustomZoneMenu(bool canModifyCard)
addSeparator();
addAction(aClone);
addMenu(new MoveMenu(player));
addMenu(new MoveMenu(player, this));
// actions that are really wonky when done from deck or sideboard
if (card->getZone()->getName() == ZoneNames::HAND) {
@ -366,7 +366,7 @@ void CardMenu::createHandOrCustomZoneMenu(bool canModifyCard)
void CardMenu::createZonelessMenu(bool canModifyCard)
{
if (canModifyCard) {
addMenu(new MoveMenu(player));
addMenu(new MoveMenu(player, this));
}
}
@ -415,7 +415,7 @@ void CardMenu::addRelatedCardView()
}
addSeparator();
auto viewRelatedCards = new QMenu(tr("View related cards"));
auto viewRelatedCards = new QMenu(tr("View related cards"), this);
addMenu(viewRelatedCards);
for (const CardRelation *relatedCard : relatedCards) {
QString relatedCardName = relatedCard->getName();
@ -463,16 +463,16 @@ void CardMenu::addRelatedCardActions()
relatedCardName = relatedCard.getName(); // "name"
}
QString text = tr("Token: ");
QString text;
if (cardRelation->getDoesAttach()) {
text +=
tr(cardRelation->getDoesTransform() ? "Transform into " : "Attach to ") + "\"" + relatedCardName + "\"";
text = cardRelation->getDoesTransform() ? tr("Token: Transform into \"%1\"").arg(relatedCardName)
: tr("Token: Attach to \"%1\"").arg(relatedCardName);
} else if (cardRelation->getIsVariable()) {
text += "X " + relatedCardName;
text = tr("Token: X %1").arg(relatedCardName);
} else if (cardRelation->getDefaultCount() != 1) {
text += QString::number(cardRelation->getDefaultCount()) + "x " + relatedCardName;
text = tr("Token: %1x %2").arg(cardRelation->getDefaultCount()).arg(relatedCardName);
} else {
text += relatedCardName;
text = tr("Token: %1").arg(relatedCardName);
}
if (createRelatedCards == nullptr) {
@ -587,4 +587,4 @@ void CardMenu::setShortcutsActive()
aRemoveCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aRC" + colorWords[i]));
aSetCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aSC" + colorWords[i]));
}
}
}

View file

@ -10,7 +10,7 @@
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_logic.h>
MoveMenu::MoveMenu(PlayerGraphicsItem *player) : QMenu(tr("Move to"))
MoveMenu::MoveMenu(PlayerGraphicsItem *player, QWidget *parent) : QMenu(tr("Move to"), parent)
{
aMoveToTopLibrary = new QAction(this);
aMoveToTopLibrary->setData(cmMoveToTopLibrary);

View file

@ -17,7 +17,7 @@ class MoveMenu : public QMenu
Q_OBJECT
public:
explicit MoveMenu(PlayerGraphicsItem *player);
explicit MoveMenu(PlayerGraphicsItem *player, QWidget *parent);
void setShortcutsActive();
void retranslateUi();

View file

@ -23,6 +23,7 @@
#include <QMenu>
#include <QString>
#include <libcockatrice/game/abstract_game.h>
#include <libcockatrice/game/game_meta_info.h>
#include <libcockatrice/game/player/player_info.h>
#include <libcockatrice/game/player/player_logic.h>
#include <libcockatrice/game/player/player_manager.h>
@ -70,6 +71,11 @@ PlayerMenu::PlayerMenu(PlayerGraphicsItem *_player) : QObject(_player), player(_
connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this,
&PlayerMenu::refreshShortcuts);
// Re-evaluate shortcuts when the game starts or ends, so the lobby only exposes the roll-die shortcut.
connect(player->getLogic()->getGame()->getGameMetaInfo(), &GameMetaInfo::startedChanged, this,
&PlayerMenu::refreshShortcuts);
refreshShortcuts();
retranslateUi();
@ -144,6 +150,12 @@ void PlayerMenu::refreshShortcuts()
void PlayerMenu::setShortcutsActive()
{
shortcutsActive = true;
if (!isGameStarted()) {
applyLobbyShortcuts();
return;
}
for (auto *c : managedComponents) {
c->setShortcutsActive();
}
@ -156,5 +168,24 @@ void PlayerMenu::setShortcutsInactive()
for (auto *c : managedComponents) {
c->setShortcutsInactive();
}
emit shortcutsDeactivated();
}
bool PlayerMenu::isGameStarted() const
{
return player->getLogic()->getGame()->getGameMetaInfo()->started();
}
void PlayerMenu::applyLobbyShortcuts()
{
// Everything stays unbound until the game starts; only the roll-die shortcut is usable in the lobby.
for (auto *c : managedComponents) {
c->setShortcutsInactive();
}
if (utilityMenu) {
utilityMenu->setLobbyShortcutsActive();
}
emit shortcutsDeactivated();
}

View file

@ -73,17 +73,24 @@ public:
return utilityMenu;
}
/** @brief Whether this player currently holds its shortcuts and the game has started. */
[[nodiscard]] bool getShortcutsActive() const
{
return shortcutsActive;
return shortcutsActive && isGameStarted();
}
/** @brief Bind keyboard shortcuts. Called when this player gains focus. */
/** @brief Bind keyboard shortcuts. Only the roll-die shortcut is bound before the game starts. */
void setShortcutsActive();
/** @brief Unbind keyboard shortcuts. Called when this player loses focus. */
void setShortcutsInactive();
private:
/** @brief True once the game has started; false while players are still in the deck-selection lobby. */
[[nodiscard]] bool isGameStarted() const;
/** @brief Unbinds everything except the shortcuts that remain usable before the game starts. */
void applyLobbyShortcuts();
PlayerGraphicsItem *player;
TearOffMenu *playerMenu;
QMenu *countersMenu;

View file

@ -9,7 +9,7 @@
#include <libcockatrice/game/player/player_actions.h>
#include <libcockatrice/game/player/player_logic.h>
PtMenu::PtMenu(PlayerGraphicsItem *player) : QMenu(tr("Power / toughness"))
PtMenu::PtMenu(PlayerGraphicsItem *player, QWidget *parent) : QMenu(tr("Power / toughness"), parent)
{
PlayerActions *playerActions = player->getLogic()->getPlayerActions();

View file

@ -18,7 +18,7 @@ class PtMenu : public QMenu
Q_OBJECT
public:
explicit PtMenu(PlayerGraphicsItem *player);
explicit PtMenu(PlayerGraphicsItem *player, QWidget *parent);
void retranslateUi();
void setShortcutsActive();

View file

@ -150,4 +150,13 @@ void UtilityMenu::setShortcutsInactive()
aCreateAnotherToken->setShortcut(QKeySequence());
aIncrementAllCardCounters->setShortcut(QKeySequence());
}
}
void UtilityMenu::setLobbyShortcutsActive()
{
if (!player->getLogic()->getPlayerInfo()->getLocalOrJudge()) {
return;
}
aRollDie->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("Player/aRollDie"));
}

View file

@ -32,6 +32,9 @@ public slots:
public:
explicit UtilityMenu(PlayerGraphicsItem *player, QMenu *playerMenu);
/** @brief Binds only the shortcuts that remain usable before the game starts. */
void setLobbyShortcutsActive();
[[nodiscard]] bool createAnotherTokenActionExists() const
{
return aCreateAnotherToken != nullptr;

View file

@ -1,21 +1,27 @@
#include "view_zone.h"
#include "../../client/settings/cache_settings.h"
#include "../../interface/theme_manager.h"
#include "../board/card_drag_item.h"
#include "../board/card_item.h"
#include "../card_dimensions.h"
#include "../z_values.h"
#include "libcockatrice/card/printing/exact_card.h"
#include "libcockatrice/filters/filter_string.h"
#include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_zone.pb.h"
#include "libcockatrice/utility/card_ref.h"
#include <QApplication>
#include <QBrush>
#include <QColor>
#include <QDebug>
#include <QFont>
#include <QFontMetrics>
#include <QMessageLogger>
#include <QObject>
#include <QPainter>
#include <QPalette>
#include <QSharedPointer>
#include <QtGlobal>
#include <QtMath>
@ -34,12 +40,85 @@
class QGraphicsItem;
namespace
{
constexpr qreal MARKER_HORIZONTAL_PADDING = 4.0;
constexpr qreal MARKER_VERTICAL_PADDING = 2.0;
constexpr qreal MARKER_CORNER_RADIUS = 4.0;
constexpr qreal MARKER_INSET = 2.0;
constexpr int MARKER_FONT_SIZE = 9;
// Vertical pitch of a card column: each card shows this much before the next
// card overlaps it. Marker placement uses it to stay on the visible strip.
constexpr qreal CARD_STACK_OFFSET = CardDimensions::HEIGHT_F / 3.0;
QFont markerFont()
{
QFont font = themeManager != nullptr ? themeManager->appFont(AppFont::CardText) : QFont();
font.setPixelSize(MARKER_FONT_SIZE);
font.setWeight(QFont::Bold);
return font;
}
} // namespace
LibraryEdgeMarker::LibraryEdgeMarker(QGraphicsItem *parent) : QGraphicsItem(parent)
{
setAcceptedMouseButtons(Qt::NoButton);
setAcceptHoverEvents(false);
}
void LibraryEdgeMarker::setLabel(const QString &newLabel, const QString &toolTip)
{
if (label == newLabel && this->toolTip() == toolTip) {
return;
}
prepareGeometryChange();
label = newLabel;
const QFontMetrics metrics(markerFont());
markerRect = QRectF(0, 0, metrics.horizontalAdvance(label) + 2 * MARKER_HORIZONTAL_PADDING,
metrics.height() + 2 * MARKER_VERTICAL_PADDING);
setToolTip(toolTip);
update();
}
QRectF LibraryEdgeMarker::boundingRect() const
{
return markerRect;
}
void LibraryEdgeMarker::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/)
{
if (label.isEmpty()) {
return;
}
painter->save();
painter->setRenderHint(QPainter::Antialiasing, true);
painter->setFont(markerFont());
// ToolTipBase/ToolTipText are the palette's designed background/text pair
// for small overlays, so the chip keeps its contrast in every theme.
const QPalette palette = qApp->palette();
painter->setPen(QPen(palette.color(QPalette::Mid), 1));
painter->setBrush(palette.color(QPalette::ToolTipBase));
painter->drawRoundedRect(markerRect.adjusted(0.5, 0.5, -0.5, -0.5), MARKER_CORNER_RADIUS, MARKER_CORNER_RADIUS);
painter->setPen(palette.color(QPalette::ToolTipText));
painter->drawText(markerRect, Qt::AlignCenter, label);
painter->restore();
}
/**
* @param parent the parent QGraphicsWidget containing the reveal zone
*/
ZoneViewZone::ZoneViewZone(ZoneViewZoneLogic *_logic, QGraphicsItem *parent)
: SelectZone(_logic, parent), bRect(QRectF()), minRows(0), groupBy(CardList::NoSort), sortBy(CardList::NoSort)
{
libraryEdgeMarker = new LibraryEdgeMarker(this);
libraryEdgeMarker->setVisible(false);
auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
if (!(viewLogic->getRevealZone() && !viewLogic->getWriteableRevealZone())) {
viewLogic->getOriginalZone()->getViews().append(viewLogic);
@ -183,6 +262,8 @@ void ZoneViewZone::reorganizeCards()
gridSize = positionCardsForDisplay(cardsToDisplay);
}
updateLibraryEdgeMarker(cardsToDisplay);
// determine bounding rect
qreal aleft = 0;
qreal atop = 0;
@ -232,7 +313,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
lastColumnProp = columnProp;
qreal x = col * CardDimensions::WIDTH_F;
qreal y = row * CardDimensions::HEIGHT_F / 3;
qreal y = row * CARD_STACK_OFFSET;
cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y);
cItem->setRealZValue(i);
longestRow = qMax(row, longestRow);
@ -261,7 +342,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
for (int i = 0; i < cardCount; i++) {
auto *cItem = qobject_cast<CardItem *>(cards.at(i)->parent());
qreal x = (i / rows) * CardDimensions::WIDTH_F;
qreal y = (i % rows) * CardDimensions::HEIGHT_F / 3;
qreal y = (i % rows) * CARD_STACK_OFFSET;
cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y);
cItem->setRealZValue(i);
}
@ -270,6 +351,75 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
}
}
void ZoneViewZone::updateLibraryEdgeMarker(const CardList &displayedCards)
{
auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
if (viewLogic == nullptr || !viewLogic->isLibraryEdgeView() || displayedCards.isEmpty()) {
libraryEdgeMarkerAnchorPos = QPointF();
libraryEdgeMarker->hide();
return;
}
const bool topEdge = viewLogic->showsTopOfLibrary();
CardState *edgeState = topEdge ? displayedCards.first() : displayedCards.last();
auto *edgeCard = edgeState == nullptr ? nullptr : qobject_cast<CardItem *>(edgeState->parent());
if (edgeCard == nullptr) {
libraryEdgeMarkerAnchorPos = QPointF();
libraryEdgeMarker->hide();
return;
}
libraryEdgeMarkerAnchorPos = edgeCard->pos();
applyLibraryEdgeMarkerText();
positionLibraryEdgeMarker();
// Above the hovered-card layer: the chip sits inside its card, so hovering
// that card must not let the card repaint over the indicator.
libraryEdgeMarker->setZValue(ZValues::HOVERED_CARD + 0.5);
libraryEdgeMarker->show();
}
void ZoneViewZone::positionLibraryEdgeMarker()
{
// The chip has to stay on the part of its card that is not covered by the
// next card in the column. The top card only shows its top strip, and its
// name is drawn top-left, so the chip goes to the right of that strip. The
// bottom card is fully visible, so the chip goes to its bottom-left, away
// from the bottom-right power/toughness.
const auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
const QRectF markerRect = libraryEdgeMarker->boundingRect();
const bool topEdge = viewLogic != nullptr && viewLogic->showsTopOfLibrary();
const QPointF offset = topEdge
? QPointF(CardDimensions::WIDTH_F - markerRect.width() - MARKER_INSET,
CARD_STACK_OFFSET - markerRect.height() - MARKER_INSET)
: QPointF(MARKER_INSET, CardDimensions::HEIGHT_F - markerRect.height() - MARKER_INSET);
libraryEdgeMarker->setPos(libraryEdgeMarkerAnchorPos + offset);
}
void ZoneViewZone::applyLibraryEdgeMarkerText()
{
const auto *viewLogic = qobject_cast<ZoneViewZoneLogic *>(getLogic());
if (viewLogic == nullptr) {
return;
}
const bool topEdge = viewLogic->showsTopOfLibrary();
//: Short badge on the card at the top or bottom of a viewed library. The full phrase is in the tooltip.
libraryEdgeMarker->setLabel(topEdge ? tr("Top") : tr("Bottom"),
topEdge ? tr("This card is on top of the library")
: tr("This card is at the bottom of the library"));
}
void ZoneViewZone::retranslateEdgeMarker()
{
if (libraryEdgeMarker->isVisible()) {
// The translated label can be a different width, so reposition the chip
// to keep it inside its card.
applyLibraryEdgeMarkerText();
positionLibraryEdgeMarker();
}
}
void ZoneViewZone::setFilterString(const QString &_filterString)
{
const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay();

View file

@ -9,9 +9,11 @@
#include "select_zone.h"
#include <QGraphicsItem>
#include <QGraphicsLayoutItem>
#include <QList>
#include <QLoggingCategory>
#include <QPointF>
#include <QRectF>
#include <QSizeF>
#include <QString>
@ -33,6 +35,30 @@ inline Q_LOGGING_CATEGORY(ViewZoneLog, "view_zone");
class Response;
class ServerInfo_Card;
class QGraphicsSceneWheelEvent;
class QPainter;
class QStyleOptionGraphicsItem;
class QWidget;
/**
* A small chip drawn on the card at the edge of a viewed library.
*
* It is a sibling of the card items rather than part of the zone widget, so it
* stays attached to its card when the view reflows into several columns or is
* resized. ZoneViewZone owns and positions it.
*/
class LibraryEdgeMarker : public QGraphicsItem
{
public:
explicit LibraryEdgeMarker(QGraphicsItem *parent = nullptr);
void setLabel(const QString &label, const QString &toolTip);
[[nodiscard]] QRectF boundingRect() const override;
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override;
private:
QRectF markerRect;
QString label;
};
/**
* A CardZone that is a view into another CardZone.
@ -56,6 +82,8 @@ private:
FilterString filterString = FilterString("");
CardList::SortOption groupBy, sortBy;
bool pileView;
LibraryEdgeMarker *libraryEdgeMarker;
QPointF libraryEdgeMarkerAnchorPos;
struct GridSize
{
@ -64,6 +92,9 @@ private:
};
GridSize positionCardsForDisplay(CardList &cards, CardList::SortOption pileOption = CardList::NoSort);
void updateLibraryEdgeMarker(const CardList &displayedCards);
void applyLibraryEdgeMarkerText();
void positionLibraryEdgeMarker();
void
handleDropEvent(const QList<CardDragItem *> &dragItems, CardZoneLogic *startZone, const QPoint &dropPoint) override;
@ -79,6 +110,10 @@ public:
{
return optimumRect;
}
/** @brief Re-applies the translated library edge marker text after a language change. */
void retranslateEdgeMarker();
public slots:
void addToViews();
void removeFromViews();

View file

@ -294,6 +294,7 @@ void ZoneViewWidget::retranslateUi()
shuffleCheckBox.setText(tr("shuffle when closing"));
pileViewCheckBox.setText(tr("pile view"));
zone->retranslateEdgeMarker();
}
void ZoneViewWidget::stopWindowDrag()

View file

@ -176,7 +176,7 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture
QNetworkRequest req(url);
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/,/*;q=0.8");
req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8");
// Cached entries are served straight from the disk cache even when picture downloads are
// enabled: re-fetching an already-cached image would burn the rate limit for nothing. Only a

View file

@ -56,6 +56,7 @@ DlgForgotPasswordReset::DlgForgotPasswordReset(QWidget *parent) : QDialog(parent
playernameEdit->setMaxLength(MAX_NAME_LENGTH);
playernameLabel->setBuddy(playernameEdit);
//: Code mailed to the player so they can reset their password. Not a game token.
tokenLabel = new QLabel(tr("Token:"));
tokenEdit = new QLineEdit();
tokenEdit->setMaxLength(MAX_NAME_LENGTH);

View file

@ -230,7 +230,8 @@ void DlgMyReports::onSelectionChanged()
connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse);
client->sendCommand(pend);
QString status = table->item(row, COL_STATUS)->text();
// The cell text is the translated label; the raw status lives in UserRole.
const QString status = table->item(row, COL_STATUS)->data(Qt::UserRole).toString();
bool canComment = (status == "open" || status == "assigned");
commentButton->setEnabled(canComment);
commentInput->setEnabled(canComment);

View file

@ -21,12 +21,14 @@
#include <QCheckBox>
#include <QDebug>
#include <QHBoxLayout>
#include <QHideEvent>
#include <QList>
#include <QMap>
#include <QScrollArea>
#include <QScrollBar>
#include <QSharedPointer>
#include <QSizePolicy>
#include <QShowEvent>
#include <QString>
#include <QTimer>
#include <QVBoxLayout>
@ -54,6 +56,9 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck
setLayout(layout);
widgetLoadingBufferTimer = new QTimer(this);
deckChangeTimer = new QTimer(this);
deckChangeTimer->setSingleShot(true);
connect(deckChangeTimer, &QTimer::timeout, this, &PrintingSelector::updateDisplay);
flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded);
@ -122,11 +127,36 @@ void PrintingSelector::retranslateUi()
void PrintingSelector::printingsInDeckChanged()
{
if (SettingsCache::instance().cardsDisplay().getBumpSetsWithCardsInDeckToTop()) {
// Delay the update to avoid race conditions
QTimer::singleShot(100, this, &PrintingSelector::updateDisplay);
displayDirty = true;
if (isVisible()) {
// Coalesce deck changes while the model settles.
deckChangeTimer->start(100);
} else {
widgetLoadingBufferTimer->stop();
}
}
}
void PrintingSelector::showEvent(QShowEvent *event)
{
QWidget::showEvent(event);
if (displayDirty) {
updateDisplay();
}
}
void PrintingSelector::hideEvent(QHideEvent *event)
{
// A partially built batch must be rebuilt on reveal, not resumed with
// stale printing data after the selected card or deck changes.
if (widgetLoadingBufferTimer->isActive() || deckChangeTimer->isActive()) {
widgetLoadingBufferTimer->stop();
deckChangeTimer->stop();
displayDirty = true;
}
QWidget::hideEvent(event);
}
/**
* @return A map of uuid to amounts (main, side, tokens).
*/
@ -173,6 +203,8 @@ void PrintingSelector::updateCardAmounts()
*/
void PrintingSelector::updateDisplay()
{
deckChangeTimer->stop();
displayDirty = false;
widgetLoadingBufferTimer->stop();
widgetLoadingBufferTimer->deleteLater();
widgetLoadingBufferTimer = new QTimer(this);
@ -199,20 +231,18 @@ void PrintingSelector::updateDisplay()
*/
void PrintingSelector::setCard(const CardInfoPtr &newCard)
{
if (newCard.isNull()) {
selectedCard = newCard;
updateDisplay();
return;
}
// we don't need to redraw the widget if the card is the same
if (!selectedCard.isNull() && selectedCard->getName() == newCard->getName()) {
// Same-name cards may be different database snapshots after a reload.
if (selectedCard == newCard) {
return;
}
widgetLoadingBufferTimer->stop();
deckChangeTimer->stop();
selectedCard = newCard;
if (isVisible()) {
updateDisplay();
} else {
displayDirty = true;
}
flowWidget->setMinimumSizeToMaxSizeHint();
flowWidget->scrollArea->verticalScrollBar()->setValue(0);

View file

@ -18,6 +18,8 @@ class QCheckBox;
class QHBoxLayout;
class QString;
class QTimer;
class QShowEvent;
class QHideEvent;
class QVBoxLayout;
class SettingsButtonWidget;
template <class Key, class T> class QMap;
@ -47,6 +49,10 @@ public:
void setCard(const CardInfoPtr &newCard);
void getAllSetsForCurrentCard();
protected:
void showEvent(QShowEvent *event) override;
void hideEvent(QHideEvent *event) override;
public slots:
void retranslateUi();
void updateDisplay();
@ -88,6 +94,8 @@ private:
DeckStateManager *deckStateManager;
CardInfoPtr selectedCard;
QTimer *widgetLoadingBufferTimer;
QTimer *deckChangeTimer;
bool displayDirty = false;
int currentIndex = 0;
};

View file

@ -266,9 +266,13 @@ void GameSelector::actCreate()
return;
}
DlgCreateGame dlg(room, room->getGameTypes(), this);
dlg.exec();
updateTitle();
// Heap-allocate the dialog: if this selector (or its room tab) is destroyed while the
// dialog is open, e.g. on server disconnect, Qt deletes its children. A stack-allocated
// child would then be deleted by Qt and crash.
auto *dlg = new DlgCreateGame(room, room->getGameTypes(), this);
dlg->setAttribute(Qt::WA_DeleteOnClose);
connect(dlg, &QDialog::finished, this, &GameSelector::updateTitle);
dlg->exec();
}
void GameSelector::checkResponse(const Response &response)
@ -362,8 +366,9 @@ void GameSelector::customContextMenu(const QPoint &point)
const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt());
const QMap<int, QString> &gameTypes = gameListModel->getGameTypes().value(gameInfo.room_id());
DlgCreateGame dlg(gameInfo, gameTypes, this);
dlg.exec();
auto *dlg = new DlgCreateGame(gameInfo, gameTypes, this);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->exec();
});
QAction copyLink(tr("Cop&y game link"));

View file

@ -83,6 +83,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent,
aDemoteFromDeveloper = new QAction(QString(), this);
aGetAdminNotes = new QAction(QString(), this);
aInvestigateUser = new QAction(QString(), this);
aInvestigateAlts = new QAction(QString(), this);
retranslateUi();
}
@ -111,6 +112,7 @@ void UserContextMenu::retranslateUi()
aDemoteFromDeveloper->setText(tr("Demote user from de&veloper"));
aGetAdminNotes->setText(tr("View admin notes"));
aInvestigateUser->setText(tr("Investigate user"));
aInvestigateAlts->setText(tr("Investigate &alts"));
}
void UserContextMenu::gamesOfUserReceived(const Response &resp, const CommandContainer &commandContainer)
@ -454,6 +456,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
menu->addSeparator();
menu->addAction(aGetAdminNotes);
menu->addAction(aInvestigateUser);
menu->addAction(aInvestigateAlts);
menu->addSeparator();
if (userLevel.testFlag(ServerInfo_User::IsModerator) &&
@ -499,6 +502,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
aBanHistory->setEnabled(anotherUser);
aGetAdminNotes->setEnabled(anotherUser);
aInvestigateUser->setEnabled(anotherUser);
aInvestigateAlts->setEnabled(anotherUser);
aPromoteToMod->setEnabled(anotherUser);
aDemoteFromMod->setEnabled(anotherUser);
aPromoteToJudge->setEnabled(anotherUser);
@ -553,6 +557,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
execAdminNotes(userName);
} else if (actionClicked == aInvestigateUser) {
execInvestigateUser(userName);
} else if (actionClicked == aInvestigateAlts) {
execInvestigateAlts(userName);
} else if (actionClicked == aCopyToClipBoard) {
QClipboard *clipboard = QGuiApplication::clipboard();
clipboard->setText(deckHash);
@ -745,6 +751,11 @@ void UserContextMenu::execInvestigateUser(const QString &userName)
tabSupervisor->openTabModeration(userName);
}
void UserContextMenu::execInvestigateAlts(const QString &userName)
{
tabSupervisor->openTabAlts(userName);
}
void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod)
{
Command_AdjustMod cmd;

View file

@ -54,6 +54,7 @@ private:
QAction *aGetAdminNotes;
std::function<QList<GameInviteOption>()> gameInviteLinkProvider;
QAction *aInvestigateUser;
QAction *aInvestigateAlts;
signals:
void openMessageDialog(const QString &userName, bool focus);
private slots:
@ -127,6 +128,7 @@ public:
void execWarnHistory(const QString &userName);
void execAdminNotes(const QString &userName);
void execInvestigateUser(const QString &userName);
void execInvestigateAlts(const QString &userName);
void execAdjustMod(const QString &userName, bool shouldBeMod);
void execAdjustJudge(const QString &userName, bool shouldBeJudge);
void execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper);

View file

@ -146,6 +146,7 @@ AppearanceSettingsPage::AppearanceSettingsPage()
});
homeTabBackgroundShuffleFrequencySpinBox.setRange(0, 3600);
//: Unit following a number of seconds. Keep the leading space unless your language writes number and unit flush.
homeTabBackgroundShuffleFrequencySpinBox.setSuffix(tr(" seconds"));
homeTabBackgroundShuffleFrequencySpinBox.setValue(settings.appearance().getHomeTabBackgroundShuffleFrequency());
connect(&homeTabBackgroundShuffleFrequencySpinBox, qOverload<int>(&QSpinBox::valueChanged), &settings.appearance(),
@ -675,7 +676,7 @@ void AppearanceSettingsPage::retranslateUi()
}
handGroupBox->setTitle(tr("Hand layout"));
horizontalHandCheckBox.setText(tr("Display hand horizontally (wastes space)"));
horizontalHandCheckBox.setText(tr("Display hand horizontally (requires more space)"));
leftJustifiedHandCheckBox.setText(tr("Enable left justification"));
tableGroupBox->setTitle(tr("Table grid layout"));

View file

@ -88,18 +88,21 @@ MessagesSettingsPage::MessagesSettingsPage()
auto *chatGrid = new QGridLayout;
chatGrid->addWidget(&chatMentionCheckBox, 0, 0);
chatGrid->addWidget(&invertMentionForeground, 0, 1);
chatGrid->addWidget(&mentionColorStringLabel, 0, 1, Qt::AlignRight);
chatGrid->addWidget(mentionColor, 0, 2);
chatGrid->addWidget(&chatMentionCompleterCheckbox, 1, 0);
chatGrid->addWidget(&ignoreUnregUsersMainChat, 2, 0);
chatGrid->addWidget(&hexLabel, 1, 2);
chatGrid->addWidget(&ignoreUnregUserMessages, 3, 0);
chatGrid->addWidget(&ignoreNonBuddyUserMessages, 4, 0);
chatGrid->addWidget(&messagePopups, 5, 0);
chatGrid->addWidget(&mentionPopups, 6, 0);
chatGrid->addWidget(&roomHistory, 7, 0);
chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0);
chatGrid->addWidget(&useGameTimeCheckBox, 9, 0);
chatGrid->addWidget(&invertMentionForeground, 0, 3);
chatGrid->addWidget(&chatMentionCompleterCheckbox, 1, 0, 1, -1);
chatGrid->addWidget(&ignoreUnregUsersMainChat, 2, 0, 1, -1);
chatGrid->addWidget(&ignoreUnregUserMessages, 3, 0, 1, -1);
chatGrid->addWidget(&ignoreNonBuddyUserMessages, 4, 0, 1, -1);
chatGrid->addWidget(&messagePopups, 5, 0, 1, -1);
chatGrid->addWidget(&mentionPopups, 6, 0, 1, -1);
chatGrid->addWidget(&roomHistory, 7, 0, 1, -1);
chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0, 1, -1);
chatGrid->addWidget(&useGameTimeCheckBox, 9, 0, 1, -1);
// Expand the first column more than the following ones
chatGrid->setColumnStretch(0, 3);
chatGrid->setColumnStretch(1, 1);
chatGroupBox = new QGroupBox;
chatGroupBox->setLayout(chatGrid);
@ -109,11 +112,14 @@ MessagesSettingsPage::MessagesSettingsPage()
connect(highlightColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateHighlightColor);
auto *highlightNotice = new QGridLayout;
highlightNotice->addWidget(highlightColor, 0, 2);
highlightNotice->addWidget(&invertHighlightForeground, 0, 1);
highlightNotice->addWidget(&hexHighlightLabel, 1, 2);
highlightNotice->addWidget(customAlertString, 0, 0);
highlightNotice->addWidget(&customAlertStringLabel, 1, 0);
highlightNotice->addWidget(&highlightColorStringLabel, 0, 1, Qt::AlignRight);
highlightNotice->addWidget(highlightColor, 0, 2);
highlightNotice->addWidget(&invertHighlightForeground, 0, 3);
highlightNotice->addWidget(&customAlertStringLabel, 1, 0, 1, -1);
// Expand the first column more than the following ones
highlightNotice->setColumnStretch(0, 3);
highlightNotice->setColumnStretch(1, 1);
customAlertStringLabel.setBuddy(customAlertString);
highlightGroupBox = new QGroupBox;
highlightGroupBox->setLayout(highlightNotice);
@ -131,10 +137,8 @@ MessagesSettingsPage::MessagesSettingsPage()
auto *channelGrid = new QGridLayout;
channelGrid->addWidget(&channelUnreadColorLabel, 0, 0);
channelGrid->addWidget(channelUnreadColor, 0, 1);
channelGrid->addWidget(&hexChannelUnreadLabel, 0, 2);
channelGrid->addWidget(&channelMentionColorLabel, 1, 0);
channelGrid->addWidget(channelMentionColor, 1, 1);
channelGrid->addWidget(&hexChannelMentionLabel, 1, 2);
channelGroupBox = new QGroupBox;
channelGroupBox->setLayout(channelGrid);
@ -331,6 +335,8 @@ void MessagesSettingsPage::retranslateUi()
channelGroupBox->setTitle(tr("Room channel tabs"));
channelUnreadColorLabel.setText(tr("Unread messages color"));
channelMentionColorLabel.setText(tr("Mentioned messages color"));
channelUnreadColor->setPlaceholderText(tr("Enter HEX code"));
channelMentionColor->setPlaceholderText(tr("Enter HEX code"));
chatMentionCheckBox.setText(tr("Enable chat mentions"));
chatMentionCompleterCheckbox.setText(tr("Enable mention completer"));
messageGroupBox->setTitle(tr("In-game message macros"));
@ -341,15 +347,15 @@ void MessagesSettingsPage::retranslateUi()
ignoreNonBuddyUserMessages.setText(tr("Ignore private messages sent by non-buddy users"));
invertMentionForeground.setText(tr("Invert text color"));
invertHighlightForeground.setText(tr("Invert text color"));
mentionColorStringLabel.setText(tr("Color"));
highlightColorStringLabel.setText(tr("Color"));
mentionColor->setPlaceholderText(tr("Enter HEX code"));
highlightColor->setPlaceholderText(tr("Enter HEX code"));
messagePopups.setText(tr("Enable desktop notifications for private messages"));
mentionPopups.setText(tr("Enable desktop notification for mentions"));
roomHistory.setText(tr("Enable room message history on join"));
ignoreAllPrivateMessagesCheckBox.setText(tr("Ignore all private messages"));
useGameTimeCheckBox.setText(tr("Use game time instead of local time in game logs"));
hexLabel.setText(tr("(Color is hexadecimal)"));
hexHighlightLabel.setText(tr("(Color is hexadecimal)"));
hexChannelUnreadLabel.setText(tr("(Color is hexadecimal)"));
hexChannelMentionLabel.setText(tr("(Color is hexadecimal)"));
customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only"));
customAlertString->setPlaceholderText(tr("Word1 Word2 Word3"));
aAdd->setText(tr("Add New Message"));

View file

@ -58,13 +58,11 @@ private:
QLineEdit *channelUnreadColor;
QLineEdit *channelMentionColor;
QLineEdit *customAlertString;
QLabel hexLabel;
QLabel hexHighlightLabel;
QLabel channelUnreadColorLabel;
QLabel channelMentionColorLabel;
QLabel hexChannelUnreadLabel;
QLabel hexChannelMentionLabel;
QLabel customAlertStringLabel;
QLabel mentionColorStringLabel;
QLabel highlightColorStringLabel;
QLabel explainMessagesLabel;
void storeSettings();

View file

@ -0,0 +1,109 @@
#include "alt_table_delegate.h"
#include "../utility/pill_painter.h"
#include <QApplication>
#include <QModelIndex>
#include <QPainter>
#include <QPalette>
#include <QStyle>
#include <QStyleOptionViewItem>
namespace
{
constexpr int ICON_TEXT_GAP = 4;
} // namespace
AltTableDelegate::AltTableDelegate(int viaColumn,
int warnsColumn,
int bansColumn,
int sharedColumn,
int activeColumn,
QObject *parent)
: QStyledItemDelegate(parent), viaColumn(viaColumn), warnsColumn(warnsColumn), bansColumn(bansColumn),
sharedColumn(sharedColumn), activeColumn(activeColumn)
{
}
void AltTableDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
{
const bool selected = (option.state & QStyle::State_Selected) != 0;
QStyleOptionViewItem opt(option);
initStyleOption(&opt, index);
painter->setFont(opt.font);
QStyle *style = opt.widget != nullptr ? opt.widget->style() : QApplication::style();
const QString text = index.data(Qt::DisplayRole).toString();
const QPalette::ColorRole emphasisRole = selected ? QPalette::HighlightedText : QPalette::WindowText;
if (index.column() == viaColumn) {
bool sharedOk = false;
const unsigned shared = index.sibling(index.row(), sharedColumn).data(Qt::DisplayRole).toUInt(&sharedOk);
const bool ipMatch = text.startsWith(QStringLiteral("ip:"));
// shared_count for an IP match already includes the investigated account,
// so anything above 2 means the address is shared beyond that pair.
const bool dimmed = sharedOk && ipMatch && shared > 2;
const QPalette::ColorRole textRole = (dimmed && !selected) ? QPalette::PlaceholderText : emphasisRole;
QPalette::ColorRole tintRole = QPalette::NoRole;
if (text == QStringLiteral("email")) {
tintRole = QPalette::Link;
} else if (text.startsWith(QStringLiteral("clientid:"))) {
tintRole = QPalette::Highlight;
} else if (ipMatch) {
tintRole = QPalette::PlaceholderText;
}
if (tintRole != QPalette::NoRole) {
if (!pill_painter::paint(painter, opt, tintRole, textRole, text)) {
QStyledItemDelegate::paint(painter, opt, index);
}
return;
}
QStyledItemDelegate::paint(painter, opt, index);
return;
}
if (index.column() == warnsColumn || index.column() == bansColumn) {
bool countOk = false;
const int count = text.toInt(&countOk);
if (countOk && count > 0 && !selected) {
const QIcon icon = style->standardIcon(index.column() == bansColumn ? QStyle::SP_MessageBoxCritical
: QStyle::SP_MessageBoxWarning);
const int iconSize = qMax(0, opt.rect.height() - 2 * ICON_TEXT_GAP);
QRect textRect = opt.rect;
if (!icon.isNull() && iconSize > 0) {
const QRect iconRect(opt.rect.left() + ICON_TEXT_GAP, opt.rect.top() + ICON_TEXT_GAP, iconSize,
iconSize);
icon.paint(painter, iconRect);
textRect.setLeft(iconRect.right() + ICON_TEXT_GAP);
}
const QString elided = opt.fontMetrics.elidedText(text, opt.textElideMode, textRect.width());
style->drawItemText(painter, textRect, Qt::AlignLeft | Qt::AlignVCenter, opt.palette, true, elided,
emphasisRole);
return;
}
}
if (index.column() == activeColumn) {
const bool active = index.data(Qt::UserRole).toBool();
const QPalette::ColorRole tintRole = active ? QPalette::Highlight : QPalette::PlaceholderText;
if (!pill_painter::paint(painter, opt, tintRole, emphasisRole, text)) {
QStyledItemDelegate::paint(painter, opt, index);
}
return;
}
QStyledItemDelegate::paint(painter, opt, index);
}
QSize AltTableDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
{
const QSize hint = QStyledItemDelegate::sizeHint(option, index);
if (index.column() == viaColumn || index.column() == activeColumn) {
return hint + QSize(pill_painter::PAD_X + 2 * ICON_TEXT_GAP, pill_painter::PAD_Y);
}
if (index.column() == warnsColumn || index.column() == bansColumn) {
return hint + QSize(2 * ICON_TEXT_GAP, 0);
}
return hint;
}

View file

@ -0,0 +1,37 @@
#ifndef ALT_TABLE_DELEGATE_H
#define ALT_TABLE_DELEGATE_H
#include <QStyledItemDelegate>
#include <qtmetamacros.h>
/**
* Badge + icon painting for the User Alts table. The match-evidence column
* renders as a palette-tinted pill per match type (email / client id / IP), and
* an IP shared by more than one other account is dimmed. Warn/ban counts gain
* the style's warning/critical icons when nonzero. The active column renders as
* a palette-tinted pill (Highlight when active, PlaceholderText when inactive,
* from Qt::UserRole). Selected rows and unknown content fall back to default
* painting.
*/
class AltTableDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
explicit AltTableDelegate(int viaColumn,
int warnsColumn,
int bansColumn,
int sharedColumn,
int activeColumn,
QObject *parent = nullptr);
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
[[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
private:
int viaColumn;
int warnsColumn;
int bansColumn;
int sharedColumn;
int activeColumn;
};
#endif // ALT_TABLE_DELEGATE_H

View file

@ -38,7 +38,8 @@ public:
[[nodiscard]] QString getTabText() const override
{
auto cardName = cardToQuery.isNull() ? QString() : cardToQuery->getName();
return tr("EDHRec: ") + cardName;
//: EDHRec is a website name, keep it as written. %1 is the card being looked up.
return tr("EDHRec: %1").arg(cardName);
}
CardSizeWidget *getCardSizeSlider() const

View file

@ -0,0 +1,54 @@
#include "report_status_delegate.h"
#include "../utility/pill_painter.h"
#include <QApplication>
#include <QPainter>
#include <QPalette>
#include <QStyle>
#include <QStyleOptionViewItem>
ReportStatusDelegate::ReportStatusDelegate(QObject *parent) : QStyledItemDelegate(parent)
{
}
void ReportStatusDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
{
const bool selected = (option.state & QStyle::State_Selected) != 0;
QString status = index.data(Qt::UserRole).toString();
if (status.isEmpty()) {
status = index.data(Qt::DisplayRole).toString();
}
QPalette::ColorRole tintRole = QPalette::NoRole;
if (status == QStringLiteral("open")) {
tintRole = QPalette::Highlight;
} else if (status == QStringLiteral("assigned")) {
tintRole = QPalette::Link;
} else if (status == QStringLiteral("resolved") || status == QStringLiteral("dismissed")) {
tintRole = QPalette::PlaceholderText;
}
QStyleOptionViewItem opt(option);
initStyleOption(&opt, index);
painter->setFont(opt.font);
if (tintRole == QPalette::NoRole) {
const QPalette::ColorRole textRole = selected ? QPalette::HighlightedText : QPalette::PlaceholderText;
QStyle *fallbackStyle = opt.widget != nullptr ? opt.widget->style() : QApplication::style();
fallbackStyle->drawItemText(painter, opt.rect, Qt::AlignLeft | Qt::AlignVCenter, opt.palette, true, opt.text,
textRole);
return;
}
const QPalette::ColorRole textRole = selected ? QPalette::HighlightedText : QPalette::WindowText;
if (!pill_painter::paint(painter, opt, tintRole, textRole, opt.text.isEmpty() ? status : opt.text)) {
QStyledItemDelegate::paint(painter, opt, index);
}
}
QSize ReportStatusDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const
{
const QSize hint = QStyledItemDelegate::sizeHint(option, index);
return hint + QSize(pill_painter::PAD_X, pill_painter::PAD_Y);
}

View file

@ -0,0 +1,22 @@
#ifndef REPORT_STATUS_DELEGATE_H
#define REPORT_STATUS_DELEGATE_H
#include <QStyledItemDelegate>
#include <qtmetamacros.h>
/**
* Status pill for the report queue table. Paints the status text on a
* translucent badge tinted with a palette role per status (open, assigned,
* resolved, dismissed), so the colors always follow the active theme.
* Falls back to default painting for selected rows and unknown statuses.
*/
class ReportStatusDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
explicit ReportStatusDelegate(QObject *parent = nullptr);
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
[[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
};
#endif // REPORT_STATUS_DELEGATE_H

View file

@ -0,0 +1,699 @@
#include "tab_alts.h"
#include "../utility/epoch_format.h"
#include "../utility/table_activation.h"
#include "abstract_client.h"
#include "alt_table_delegate.h"
#include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/response_user_alts.pb.h"
#include <QAbstractItemView>
#include <QDateTime>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QItemSelectionModel>
#include <QLabel>
#include <QLineEdit>
#include <QList>
#include <QPushButton>
#include <QSignalBlocker>
#include <QSpinBox>
#include <QSplitter>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QTextEdit>
#include <QVBoxLayout>
#include <QWidget>
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
#include <libcockatrice/protocol/pb/response_ban_history.pb.h>
#include <libcockatrice/protocol/pb/response_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/response_warn_history.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_ban.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_warning.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <string>
namespace
{
constexpr int COL_ALTS_USER = 0;
constexpr int COL_ALTS_VIA = 1;
constexpr int COL_ALTS_DEPTH = 2;
constexpr int COL_ALTS_SHARED = 3;
constexpr int COL_ALTS_IPS = 4;
constexpr int COL_ALTS_EMAIL = 5;
constexpr int COL_ALTS_CLIENTID = 6;
constexpr int COL_ALTS_REGISTERED = 7;
constexpr int COL_ALTS_LAST_LOGIN = 8;
constexpr int COL_ALTS_WARNS = 9;
constexpr int COL_ALTS_BANS = 10;
constexpr int COL_ALTS_ACTIVE = 11;
constexpr int COL_ALTS_COUNT = 12;
constexpr int COL_WARNS_TIME = 0;
constexpr int COL_WARNS_MODERATOR = 1;
constexpr int COL_WARNS_REASON = 2;
constexpr int COL_WARNS_COUNT = 3;
constexpr int COL_BANS_TIME = 0;
constexpr int COL_BANS_MODERATOR = 1;
constexpr int COL_BANS_LENGTH = 2;
constexpr int COL_BANS_REASON = 3;
constexpr int COL_BANS_COUNT = 4;
constexpr int MAX_ALT_DEPTH = 3;
// Starting default only (adjustable 1-100000): shared VPN exits routinely
// cover dozens of users, so 10 hides the obvious VPN noise but keeps
// small shared networks visible.
constexpr int DEFAULT_VPN_THRESHOLD = 10;
void configureAltTable(QTableWidget *table)
{
table->setSelectionBehavior(QAbstractItemView::SelectRows);
table->setSelectionMode(QAbstractItemView::SingleSelection);
table->setEditTriggers(QAbstractItemView::NoEditTriggers);
table->verticalHeader()->setVisible(false);
table->setAlternatingRowColors(true);
table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
table->horizontalHeader()->setStretchLastSection(true);
}
} // namespace
TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser)
: Tab(_tabSupervisor), client(_client)
{
auto *centralWidget = new QWidget(this);
setCentralWidget(centralWidget);
searchEdit = new QLineEdit;
searchEdit->setClearButtonEnabled(true);
connect(searchEdit, &QLineEdit::returnPressed, this, &TabAlts::investigateUser);
depthSpin = new QSpinBox;
depthSpin->setMinimum(1);
depthSpin->setMaximum(MAX_ALT_DEPTH);
depthSpin->setValue(1);
depthSpin->setKeyboardTracking(false);
connect(depthSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged);
investigateButton = new QPushButton;
connect(investigateButton, &QPushButton::clicked, this, &TabAlts::investigateUser);
backButton = new QPushButton;
backButton->setEnabled(false);
connect(backButton, &QPushButton::clicked, this, &TabAlts::goBack);
statusLabel = new QLabel;
trailLabel = new QLabel;
trailLabel->setTextFormat(Qt::RichText);
trailLabel->setTextInteractionFlags(Qt::TextBrowserInteraction);
trailLabel->setOpenExternalLinks(false);
// The trail grows with every hop, so let it wrap instead of forcing the tab
// wider than the window.
trailLabel->setWordWrap(true);
connect(trailLabel, &QLabel::linkActivated, this, [this](const QString &userName) {
if (!userName.isEmpty() && userName != currentUser) {
goToTrailEntry(userName);
}
});
auto *topBar = new QHBoxLayout;
topBar->addWidget(backButton);
topBar->addWidget(searchEdit);
topBar->addWidget(depthSpin);
topBar->addWidget(investigateButton);
auto makeChip = [this](const QString &text) {
auto *chip = new QPushButton(text);
chip->setCheckable(true);
chip->setChecked(true);
connect(chip, &QPushButton::toggled, this, &TabAlts::onSearchFilterChanged);
return chip;
};
emailChip = makeChip(QString());
clientIdChip = makeChip(QString());
ipChip = makeChip(QString());
vpnLabel = new QLabel;
vpnSpin = new QSpinBox;
vpnSpin->setMinimum(1);
vpnSpin->setMaximum(100000);
vpnSpin->setValue(DEFAULT_VPN_THRESHOLD);
connect(vpnSpin, &QSpinBox::valueChanged, this, &TabAlts::onDisplayFilterChanged);
windowLabel = new QLabel;
monthsSpin = new QSpinBox;
monthsSpin->setMinimum(1);
monthsSpin->setMaximum(24);
monthsSpin->setValue(6);
monthsSpin->setKeyboardTracking(false);
connect(monthsSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged);
auto *filterBar = new QHBoxLayout;
filterBar->addWidget(emailChip);
filterBar->addWidget(clientIdChip);
filterBar->addWidget(ipChip);
filterBar->addWidget(vpnLabel);
filterBar->addWidget(vpnSpin);
filterBar->addWidget(windowLabel);
filterBar->addWidget(monthsSpin);
filterBar->addStretch();
altsTable = new QTableWidget(0, COL_ALTS_COUNT);
configureAltTable(altsTable);
altsDelegate =
new AltTableDelegate(COL_ALTS_VIA, COL_ALTS_WARNS, COL_ALTS_BANS, COL_ALTS_SHARED, COL_ALTS_ACTIVE, altsTable);
altsTable->setItemDelegateForColumn(COL_ALTS_VIA, altsDelegate);
altsTable->setItemDelegateForColumn(COL_ALTS_WARNS, altsDelegate);
altsTable->setItemDelegateForColumn(COL_ALTS_BANS, altsDelegate);
altsTable->setItemDelegateForColumn(COL_ALTS_ACTIVE, altsDelegate);
connectTableActivation(altsTable, this, [this](const QModelIndex &index) {
if (!index.isValid()) {
return;
}
auto *userItem = altsTable->item(index.row(), COL_ALTS_USER);
if (userItem != nullptr && !userItem->text().isEmpty() && userItem->text() != currentUser) {
investigate(userItem->text());
}
});
connect(altsTable, &QTableWidget::itemSelectionChanged, this, &TabAlts::onAltSelected);
detailNameValue = new QLabel;
detailCountsValue = new QLabel;
detailNotesEdit = new QTextEdit;
detailNotesEdit->setReadOnly(true);
// Compact cap so the warn/ban tables stay visible without scrolling the tab.
detailNotesEdit->setMaximumHeight(60);
warnsTable = new QTableWidget(0, COL_WARNS_COUNT);
configureAltTable(warnsTable);
bansTable = new QTableWidget(0, COL_BANS_COUNT);
configureAltTable(bansTable);
auto *detailGrid = new QGridLayout;
detailNameLabel = new QLabel;
detailCountsLabel = new QLabel;
detailNotesLabel = new QLabel;
detailGrid->addWidget(detailNameLabel, 0, 0);
detailGrid->addWidget(detailNameValue, 0, 1);
detailGrid->addWidget(detailCountsLabel, 0, 2);
detailGrid->addWidget(detailCountsValue, 0, 3);
detailGrid->addWidget(detailNotesLabel, 1, 0);
detailGrid->addWidget(detailNotesEdit, 1, 1, 1, 3);
detailsGroup = new QGroupBox;
auto *detailsLayout = new QVBoxLayout(detailsGroup);
detailsLayout->addLayout(detailGrid);
detailsLayout->addWidget(warnsTable, 1);
detailsLayout->addWidget(bansTable, 1);
investigateSelectedButton = new QPushButton;
investigateSelectedButton->setEnabled(false);
connect(investigateSelectedButton, &QPushButton::clicked, this, &TabAlts::investigateSelected);
detailsLayout->addWidget(investigateSelectedButton);
auto *contentSplitter = new QSplitter(Qt::Horizontal);
contentSplitter->addWidget(altsTable);
contentSplitter->addWidget(detailsGroup);
contentSplitter->setStretchFactor(0, 3);
contentSplitter->setStretchFactor(1, 2);
detailsGroup->setMinimumWidth(260);
auto *mainLayout = new QVBoxLayout(centralWidget);
mainLayout->addLayout(topBar);
mainLayout->addLayout(filterBar);
mainLayout->addWidget(statusLabel);
mainLayout->addWidget(trailLabel);
mainLayout->addWidget(contentSplitter, 1);
retranslateUi();
clearDetails();
investigate(initialUser);
}
void TabAlts::retranslateUi()
{
searchEdit->setPlaceholderText(tr("User name"));
depthSpin->setPrefix(tr("Depth: "));
depthSpin->setToolTip(tr("Search depth: 1 finds direct matches, higher values follow alts of alts."));
investigateButton->setText(tr("Investigate"));
backButton->setText(tr("Back"));
backButton->setToolTip(tr("Back to the previously investigated user"));
if (currentUser.isEmpty()) {
statusLabel->setText(tr("Enter a user name to investigate."));
}
emailChip->setText(tr("Email"));
emailChip->setToolTip(tr("Show or hide email matches"));
clientIdChip->setText(tr("Client ID"));
clientIdChip->setToolTip(tr("Show or hide client ID matches"));
ipChip->setText(tr("IP"));
ipChip->setToolTip(tr("Show or hide IP address matches"));
vpnLabel->setText(tr("Hide IPs shared by more than"));
vpnSpin->setSuffix(tr(" users"));
windowLabel->setText(tr("Session window"));
monthsSpin->setSuffix(tr(" months"));
monthsSpin->setToolTip(tr("How far back session IPs and client IDs are considered."));
altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("IPs"), tr("eMail"),
tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"),
tr("Active")});
altsTable->setToolTip(tr("Double-click or press Enter on a row to investigate that account"));
warnsTable->setHorizontalHeaderLabels({tr("Warning time"), tr("Moderator"), tr("Reason")});
bansTable->setHorizontalHeaderLabels({tr("Ban time"), tr("Moderator"), tr("Length"), tr("Reason")});
detailsGroup->setTitle(tr("Selected user"));
detailNameLabel->setText(tr("Name:"));
detailCountsLabel->setText(tr("Counts:"));
detailNotesLabel->setText(tr("Admin notes:"));
investigateSelectedButton->setText(tr("Investigate this user"));
investigateSelectedButton->setToolTip(tr("Open the selected account as a new investigation"));
if (!lastAlts.isEmpty()) {
renderTable();
}
}
QString TabAlts::formatEpoch(quint64 ts) const
{
return formatEpochTimestamp(ts, tr("Unknown"));
}
void TabAlts::investigate(const QString &userName)
{
if (userName.isEmpty() || userName == currentUser) {
return;
}
// Alt matches are symmetric, so revisiting an earlier account is a normal
// double-click. Truncate there instead of pushing it again, so the trail
// stays a single path.
if (backStack.contains(userName)) {
goToTrailEntry(userName);
return;
}
if (!currentUser.isEmpty()) {
backStack << currentUser;
backButton->setEnabled(true);
}
loadUser(userName);
}
void TabAlts::goToTrailEntry(const QString &userName)
{
const int index = backStack.indexOf(userName);
if (index < 0) {
investigate(userName);
return;
}
// Clicking an earlier crumb truncates the history there instead of pushing
// another entry, so the trail keeps a single path and Back still works.
backStack.erase(backStack.begin() + index, backStack.end());
backButton->setEnabled(!backStack.isEmpty());
loadUser(userName);
}
void TabAlts::goBack()
{
if (backStack.isEmpty()) {
return;
}
const QString previousUser = backStack.takeLast();
backButton->setEnabled(!backStack.isEmpty());
loadUser(previousUser);
}
void TabAlts::loadUser(const QString &userName)
{
currentUser = userName;
searchEdit->setText(userName);
updateTrail();
investigateUser();
}
void TabAlts::updateTrail()
{
QStringList trail = backStack;
trail << currentUser;
trail.removeAll(QString());
QStringList links;
for (const QString &name : trail) {
const QString safeName = name.toHtmlEscaped();
if (name == currentUser) {
links << QStringLiteral("<b>") + safeName + QStringLiteral("</b>");
} else {
links << QStringLiteral("<a href=\"") + safeName + QStringLiteral("\">") + safeName +
QStringLiteral("</a>");
}
}
trailLabel->setText(links.join(QStringLiteral(" › ")));
trailLabel->setVisible(!links.isEmpty());
}
void TabAlts::investigateUser()
{
const QString userName = searchEdit->text().simplified();
if (userName.isEmpty()) {
return;
}
if (userName != currentUser && !currentUser.isEmpty()) {
backStack << currentUser;
backButton->setEnabled(true);
}
currentUser = userName;
updateTrail();
reloadCurrentUser();
}
void TabAlts::reloadCurrentUser()
{
lastAlts.clear();
selectedUser.clear();
clearDetails();
altsTable->setRowCount(0);
statusLabel->setText(tr("Loading alts for %1...").arg(currentUser));
requestAlts(currentUser, depthSpin->value());
}
void TabAlts::requestAlts(const QString &userName, int depth)
{
Command_GetUserAlts cmd;
cmd.set_user_name(userName.toStdString());
cmd.set_depth(static_cast<google::protobuf::uint32>(depth));
cmd.set_include_email(emailChip->isChecked());
cmd.set_include_clientid(clientIdChip->isChecked());
cmd.set_include_ip(ipChip->isChecked());
cmd.set_session_months(static_cast<google::protobuf::uint32>(monthsSpin->value()));
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) {
if (userName == currentUser) {
altsResponse(response);
}
});
client->sendCommand(pend);
}
void TabAlts::altsResponse(const Response &response)
{
lastAlts.clear();
if (response.response_code() != Response::RespOk) {
statusLabel->setText(tr("Could not load alts for %1.").arg(currentUser));
return;
}
const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext);
// The server answers RespOk with no rows when the user does not exist.
if (resp.alts_size() == 0) {
statusLabel->setText(tr("User %1 not found.").arg(currentUser));
return;
}
for (int i = 0; i < resp.alts_size(); ++i) {
lastAlts << resp.alts(i);
}
renderTable();
}
bool TabAlts::rowVisible(const ServerInfo_UserAlt &alt) const
{
const QString name = QString::fromStdString(alt.user_name());
if (name == currentUser) {
return true;
}
if (!alt.has_match_reason() || alt.match_reason().empty()) {
return true;
}
const QString reason = QString::fromStdString(alt.match_reason());
if (reason == QStringLiteral("email")) {
return emailChip->isChecked();
}
if (reason.startsWith(QStringLiteral("clientid:"))) {
return clientIdChip->isChecked();
}
if (reason.startsWith(QStringLiteral("ip:"))) {
return ipChip->isChecked() &&
(!alt.has_shared_count() || alt.shared_count() <= static_cast<unsigned>(vpnSpin->value()));
}
return true;
}
void TabAlts::renderTable()
{
// Blocked so the programmatic refill neither wipes the details panel
// (via the selection-cleared path) nor refetches them on restore.
const QSignalBlocker blocker(altsTable);
altsTable->setRowCount(0);
int emailCount = 0;
int clientIdCount = 0;
int ipCount = 0;
for (const ServerInfo_UserAlt &alt : lastAlts) {
const QString reason = QString::fromStdString(alt.match_reason());
if (reason == QStringLiteral("email")) {
++emailCount;
} else if (reason.startsWith(QStringLiteral("clientid:"))) {
++clientIdCount;
} else if (reason.startsWith(QStringLiteral("ip:"))) {
++ipCount;
}
}
// The server omits unchecked match types, so keep the last count while a
// chip is off instead of relabelling it "(0)".
if (emailChip->isChecked()) {
emailChip->setText(tr("Email (%1)").arg(emailCount));
}
if (clientIdChip->isChecked()) {
clientIdChip->setText(tr("Client ID (%1)").arg(clientIdCount));
}
if (ipChip->isChecked()) {
ipChip->setText(tr("IP (%1)").arg(ipCount));
}
QFont userFont = altsTable->font();
userFont.setBold(true);
const QColor dimColor = palette().color(QPalette::Disabled, QPalette::Text);
int hidden = 0;
int row = 0;
for (const ServerInfo_UserAlt &alt : lastAlts) {
if (!rowVisible(alt)) {
++hidden;
continue;
}
altsTable->insertRow(row);
auto *userItem = new QTableWidgetItem(QString::fromStdString(alt.user_name()));
userItem->setFont(userFont);
altsTable->setItem(row, COL_ALTS_USER, userItem);
altsTable->setItem(row, COL_ALTS_VIA,
new QTableWidgetItem(alt.has_match_reason() && !alt.match_reason().empty()
? QString::fromStdString(alt.match_reason())
: QStringLiteral("-")));
altsTable->setItem(row, COL_ALTS_DEPTH,
new QTableWidgetItem(alt.has_depth() ? QString::number(alt.depth()) : QStringLiteral("-")));
altsTable->setItem(
row, COL_ALTS_SHARED,
new QTableWidgetItem(alt.has_shared_count() ? QString::number(alt.shared_count()) : QStringLiteral("-")));
altsTable->setItem(
row, COL_ALTS_IPS,
new QTableWidgetItem(alt.has_ip_count() ? QString::number(alt.ip_count()) : QStringLiteral("-")));
altsTable->setItem(row, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email())));
altsTable->setItem(row, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid())));
altsTable->setItem(row, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time())));
altsTable->setItem(row, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login())));
altsTable->setItem(row, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count())));
altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count())));
auto *activeItem = new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"));
activeItem->setData(Qt::UserRole, alt.is_active());
altsTable->setItem(row, COL_ALTS_ACTIVE, activeItem);
// shared_count already includes the investigated account, so anything
// above 2 means the address is shared beyond that pair.
if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() &&
alt.shared_count() > 2) {
for (int col = 0; col < COL_ALTS_COUNT; ++col) {
if (col == COL_ALTS_VIA) {
continue;
}
if (auto *item = altsTable->item(row, col)) {
item->setForeground(dimColor);
}
}
}
++row;
}
// The server appends the frontier user as a depth-0 seed row; it is the
// investigated user, not one of their related accounts.
int relatedCount = 0;
for (const ServerInfo_UserAlt &alt : lastAlts) {
if (!alt.has_depth() || alt.depth() > 0) {
++relatedCount;
}
}
if (hidden == 0) {
statusLabel->setText(tr("%1 account(s) related to %2.").arg(relatedCount).arg(currentUser));
} else {
statusLabel->setText(tr("%1 shown, %2 hidden by filters (%3 total) related to %4.")
.arg(row)
.arg(hidden)
.arg(relatedCount)
.arg(currentUser));
}
if (!selectedUser.isEmpty()) {
bool restored = false;
for (int restoreRow = 0; restoreRow < altsTable->rowCount(); ++restoreRow) {
auto *userItem = altsTable->item(restoreRow, COL_ALTS_USER);
if (userItem != nullptr && userItem->text() == selectedUser) {
altsTable->setCurrentItem(userItem, QItemSelectionModel::Select | QItemSelectionModel::Rows);
restored = true;
break;
}
}
if (!restored) {
selectedUser.clear();
clearDetails();
}
}
}
void TabAlts::onSearchFilterChanged()
{
if (currentUser.isEmpty()) {
return;
}
// A filter change reloads the user being investigated; it must not switch
// to a name that was typed into the search box but never submitted.
reloadCurrentUser();
}
void TabAlts::onDisplayFilterChanged()
{
if (lastAlts.isEmpty()) {
return;
}
renderTable();
}
void TabAlts::investigateSelected()
{
if (!selectedUser.isEmpty() && selectedUser != currentUser) {
investigate(selectedUser);
}
}
void TabAlts::onAltSelected()
{
const auto selected = altsTable->selectedItems();
if (selected.isEmpty()) {
selectedUser.clear();
clearDetails();
return;
}
auto *userItem = altsTable->item(selected.first()->row(), COL_ALTS_USER);
if (userItem == nullptr || userItem->text().isEmpty() || userItem->text() == selectedUser) {
return;
}
selectedUser = userItem->text();
requestDetails(selectedUser);
}
void TabAlts::requestDetails(const QString &userName)
{
detailNameValue->setText(userName);
detailCountsValue->setText(tr("Loading..."));
detailNotesEdit->clear();
warnsTable->setRowCount(0);
bansTable->setRowCount(0);
investigateSelectedButton->setEnabled(userName != currentUser);
Command_ReportUserInfo infoCmd;
infoCmd.set_user_name(userName.toStdString());
PendingCommand *infoPend = client->prepareModeratorCommand(infoCmd);
connect(infoPend, &PendingCommand::finished, this, [this, userName](const Response &response) {
if (userName == selectedUser) {
userDetailsResponse(response);
}
});
client->sendCommand(infoPend);
Command_GetWarnHistory warnCmd;
warnCmd.set_user_name(userName.toStdString());
PendingCommand *warnPend = client->prepareModeratorCommand(warnCmd);
connect(warnPend, &PendingCommand::finished, this, [this, userName](const Response &response) {
if (userName == selectedUser) {
warnHistoryResponse(response);
}
});
client->sendCommand(warnPend);
Command_GetBanHistory banCmd;
banCmd.set_user_name(userName.toStdString());
PendingCommand *banPend = client->prepareModeratorCommand(banCmd);
connect(banPend, &PendingCommand::finished, this, [this, userName](const Response &response) {
if (userName == selectedUser) {
banHistoryResponse(response);
}
});
client->sendCommand(banPend);
}
void TabAlts::clearDetails()
{
detailNameValue->clear();
detailCountsValue->setText(tr("Select a row to inspect its history."));
detailNotesEdit->clear();
warnsTable->setRowCount(0);
bansTable->setRowCount(0);
investigateSelectedButton->setEnabled(false);
}
void TabAlts::userDetailsResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
detailCountsValue->setText(tr("Error loading user info."));
return;
}
const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext);
detailCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3")
.arg(resp.total_reports())
.arg(resp.total_bans())
.arg(resp.total_warns()));
if (resp.has_admin_notes() && !resp.admin_notes().empty()) {
detailNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes()));
} else {
detailNotesEdit->setPlainText(tr("(none)"));
}
}
void TabAlts::warnHistoryResponse(const Response &response)
{
warnsTable->setRowCount(0);
if (response.response_code() != Response::RespOk) {
return;
}
const Response_WarnHistory &resp = response.GetExtension(Response_WarnHistory::ext);
warnsTable->setRowCount(resp.warn_list_size());
for (int i = 0; i < resp.warn_list_size(); ++i) {
const ServerInfo_Warning &warn = resp.warn_list(i);
warnsTable->setItem(i, COL_WARNS_TIME, new QTableWidgetItem(QString::fromStdString(warn.time_of())));
warnsTable->setItem(i, COL_WARNS_MODERATOR, new QTableWidgetItem(QString::fromStdString(warn.admin_name())));
warnsTable->setItem(i, COL_WARNS_REASON, new QTableWidgetItem(QString::fromStdString(warn.reason())));
}
}
void TabAlts::banHistoryResponse(const Response &response)
{
bansTable->setRowCount(0);
if (response.response_code() != Response::RespOk) {
return;
}
const Response_BanHistory &resp = response.GetExtension(Response_BanHistory::ext);
bansTable->setRowCount(resp.ban_list_size());
for (int i = 0; i < resp.ban_list_size(); ++i) {
const ServerInfo_Ban &ban = resp.ban_list(i);
bansTable->setItem(i, COL_BANS_TIME, new QTableWidgetItem(QString::fromStdString(ban.ban_time())));
bansTable->setItem(i, COL_BANS_MODERATOR, new QTableWidgetItem(QString::fromStdString(ban.admin_name())));
bansTable->setItem(i, COL_BANS_LENGTH, new QTableWidgetItem(QString::fromStdString(ban.ban_length())));
bansTable->setItem(i, COL_BANS_REASON, new QTableWidgetItem(QString::fromStdString(ban.ban_reason())));
}
}

View file

@ -0,0 +1,102 @@
#ifndef TAB_ALTS_H
#define TAB_ALTS_H
#include "tab.h"
#include <QList>
#include <QString>
#include <QtGlobal>
#include <libcockatrice/protocol/pb/serverinfo_user_alt.pb.h>
#include <qtmetamacros.h>
class AbstractClient;
class AltTableDelegate;
class QGroupBox;
class QLabel;
class QLineEdit;
class QPushButton;
class QSpinBox;
class QTableWidget;
class QTextEdit;
class Response;
class TabSupervisor;
/**
* Staff alt-graph investigation tool. Shows accounts related to the
* investigated user (shared email, client id, or recent IP address),
* including transitive alts-of-alts, with per-row match evidence.
* Selecting a row loads that account's warnings, bans, and admin notes.
*/
class TabAlts : public Tab
{
Q_OBJECT
public:
explicit TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser = {});
void retranslateUi() override;
[[nodiscard]] QString getTabText() const override
{
return tr("User Alts");
}
void investigate(const QString &userName);
private slots:
void investigateUser();
void goBack();
void investigateSelected();
void altsResponse(const Response &response);
void onSearchFilterChanged();
void onDisplayFilterChanged();
void onAltSelected();
void userDetailsResponse(const Response &response);
void warnHistoryResponse(const Response &response);
void banHistoryResponse(const Response &response);
private:
void loadUser(const QString &userName);
void goToTrailEntry(const QString &userName);
void updateTrail();
void reloadCurrentUser();
void requestAlts(const QString &userName, int depth);
void requestDetails(const QString &userName);
void clearDetails();
void renderTable();
[[nodiscard]] bool rowVisible(const ServerInfo_UserAlt &alt) const;
[[nodiscard]] QString formatEpoch(quint64 ts) const;
AbstractClient *client;
QString currentUser;
QString selectedUser;
QList<QString> backStack;
QList<ServerInfo_UserAlt> lastAlts;
QLineEdit *searchEdit;
QSpinBox *depthSpin;
QPushButton *investigateButton;
QPushButton *backButton;
QPushButton *investigateSelectedButton;
QLabel *trailLabel;
QLabel *statusLabel;
QTableWidget *altsTable;
AltTableDelegate *altsDelegate;
QPushButton *emailChip;
QPushButton *clientIdChip;
QPushButton *ipChip;
QLabel *vpnLabel;
QSpinBox *vpnSpin;
QLabel *windowLabel;
QSpinBox *monthsSpin;
QLabel *detailNameValue;
QLabel *detailCountsValue;
QTextEdit *detailNotesEdit;
QTableWidget *warnsTable;
QTableWidget *bansTable;
QGroupBox *detailsGroup;
QLabel *detailNameLabel;
QLabel *detailCountsLabel;
QLabel *detailNotesLabel;
};
#endif // TAB_ALTS_H

View file

@ -330,6 +330,9 @@ void TabGame::emitUserEvent()
TabGame::~TabGame()
{
if (aCardMenu && aCardMenu->menu() && aCardMenu->menu() != emptyCardMenu) {
delete aCardMenu->menu();
}
for (auto &player : game->getPlayerManager()->getPlayers()) {
player->clear();
}
@ -570,8 +573,10 @@ void TabGame::adminLockChanged(bool lock)
void TabGame::actGameInfo()
{
DlgCreateGame dlg(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this);
dlg.exec();
// Heap-allocated so that destroying this tab while the dialog is open cannot delete a stack object.
auto *dlg = new DlgCreateGame(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->exec();
}
void TabGame::actCopyGameLink()
@ -1156,10 +1161,16 @@ QString TabGame::getTabText() const
/**
* @param menu The menu to set. Pass in nullptr to set the menu to empty.
*
* The previous card menu is deleted here: QAction::setMenu() does not take ownership, so whoever installs the menu
* (this widget) has to dispose of the one it replaces. The empty placeholder menu is reused instead of deleted.
*/
void TabGame::setCardMenu(CardMenu *menu)
{
if (!aCardMenu) {
if (!aCardMenu || !emptyCardMenu) {
if (menu) {
menu->deleteLater();
}
return;
}
@ -1169,10 +1180,11 @@ void TabGame::setCardMenu(CardMenu *menu)
gameView->viewport()->update();
}
}
if (menu) {
aCardMenu->setMenu(menu);
} else {
aCardMenu->setMenu(new QMenu);
QMenu *previousMenu = aCardMenu->menu();
aCardMenu->setMenu(menu ? menu : emptyCardMenu);
if (previousMenu && previousMenu != emptyCardMenu && previousMenu != menu) {
previousMenu->deleteLater();
}
}
@ -1256,7 +1268,8 @@ void TabGame::createMenuItems()
gameMenu->addSeparator();
aCardMenu = gameMenu->addMenu(new QMenu(this));
emptyCardMenu = new QMenu(this);
aCardMenu = gameMenu->addMenu(emptyCardMenu);
// Sync the new action with the same state the dock button already shows.
updateInviteButtonState();
@ -1288,6 +1301,7 @@ void TabGame::createReplayMenuItems()
gameMenu->addAction(aLeaveGame);
aCardMenu = nullptr;
emptyCardMenu = nullptr;
addTabMenu(gameMenu);
}

View file

@ -101,6 +101,8 @@ private:
QAction *aInviteToGame = nullptr;
QList<QAction *> phaseActions;
QAction *aCardMenu;
/** @brief Placeholder submenu shown when no card is selected; reused instead of reallocated. */
QMenu *emptyCardMenu = nullptr;
/**
* @brief The actions associated with managing a QDockWidget

View file

@ -1,11 +1,14 @@
#include "tab_moderation.h"
#include "../utility/epoch_format.h"
#include "../utility/table_activation.h"
#include "abstract_client.h"
#include "libcockatrice/protocol/pb/admin_commands.pb.h"
#include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_moderator_login.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_user_session.pb.h"
#include "tab_supervisor.h"
#include <QAbstractItemView>
#include <QDateTime>
@ -21,6 +24,7 @@
#include <QSharedPointer>
#include <QSplitter>
#include <QStringList>
#include <QTabWidget>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QTextEdit>
@ -99,38 +103,67 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli
topBar->addWidget(removeBannerButton);
userInfoGroup = new QGroupBox;
userInfoNameLabel = new QLabel;
userInfoNameValue = new QLabel;
userInfoRegisteredLabel = new QLabel;
userInfoRegisteredValue = new QLabel;
userInfoLastLoginLabel = new QLabel;
userInfoLastLoginValue = new QLabel;
userInfoStatusLabel = new QLabel;
QFont displayFont = userInfoNameValue->font();
displayFont.setPointSize(displayFont.pointSize() + 6);
displayFont.setBold(true);
userInfoNameValue->setFont(displayFont);
userInfoStatusValue = new QLabel;
userInfoCountsLabel = new QLabel;
userInfoCountsValue = new QLabel;
QFont statusFont = userInfoStatusValue->font();
statusFont.setBold(true);
userInfoStatusValue->setFont(statusFont);
sublineLabel = new QLabel;
auto makeCountCard = [](const QString &title, QLabel *&value) {
auto *card = new QGroupBox(title);
value = new QLabel(QStringLiteral("–"), card);
QFont cardFont = value->font();
cardFont.setPointSize(cardFont.pointSize() + 6);
cardFont.setBold(true);
value->setFont(cardFont);
value->setAlignment(Qt::AlignCenter);
auto *cardLayout = new QVBoxLayout(card);
cardLayout->addWidget(value);
return card;
};
cardReports = makeCountCard(QString(), cardReportsValue);
cardWarns = makeCountCard(QString(), cardWarnsValue);
cardBans = makeCountCard(QString(), cardBansValue);
altsCard = new QGroupBox;
altsCountValue = new QLabel(QStringLiteral("–"), altsCard);
QFont altsFont = altsCountValue->font();
altsFont.setPointSize(altsFont.pointSize() + 6);
altsFont.setBold(true);
altsCountValue->setFont(altsFont);
altsCountValue->setAlignment(Qt::AlignCenter);
openAltsButton = new QPushButton;
connect(openAltsButton, &QPushButton::clicked, this, &TabModeration::openAltsGraph);
auto *altsCardLayout = new QVBoxLayout(altsCard);
altsCardLayout->addWidget(altsCountValue);
altsCardLayout->addWidget(openAltsButton);
auto *cardsRow = new QHBoxLayout;
cardsRow->addWidget(cardReports);
cardsRow->addWidget(cardWarns);
cardsRow->addWidget(cardBans);
cardsRow->addWidget(altsCard);
userInfoNotesLabel = new QLabel;
userInfoNotesEdit = new QTextEdit;
userInfoNotesEdit->setReadOnly(true);
auto *infoGrid = new QGridLayout;
infoGrid->addWidget(userInfoNameLabel, 0, 0);
infoGrid->addWidget(userInfoNameValue, 0, 1);
infoGrid->addWidget(userInfoRegisteredLabel, 0, 2);
infoGrid->addWidget(userInfoRegisteredValue, 0, 3);
infoGrid->addWidget(userInfoLastLoginLabel, 1, 0);
infoGrid->addWidget(userInfoLastLoginValue, 1, 1);
infoGrid->addWidget(userInfoStatusLabel, 1, 2);
infoGrid->addWidget(userInfoStatusValue, 1, 3);
infoGrid->addWidget(userInfoCountsLabel, 2, 0);
infoGrid->addWidget(userInfoCountsValue, 2, 1, 1, 3);
infoGrid->addWidget(userInfoNotesLabel, 3, 0, Qt::AlignTop);
infoGrid->addWidget(userInfoNotesEdit, 3, 1, 1, 3);
infoGrid->setColumnStretch(1, 1);
infoGrid->setColumnStretch(3, 1);
auto *identityRow = new QHBoxLayout;
identityRow->addWidget(userInfoNameValue);
identityRow->addStretch();
identityRow->addWidget(userInfoStatusValue);
auto *infoLayout = new QVBoxLayout(userInfoGroup);
infoLayout->addLayout(infoGrid);
infoLayout->addLayout(identityRow);
infoLayout->addWidget(sublineLabel);
infoLayout->addLayout(cardsRow);
infoLayout->addWidget(userInfoNotesLabel);
infoLayout->addWidget(userInfoNotesEdit);
auto configureTable = [](QTableWidget *table) {
table->setSelectionBehavior(QAbstractItemView::SelectRows);
@ -142,19 +175,27 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli
table->horizontalHeader()->setStretchLastSection(true);
};
altsGroup = new QGroupBox;
altsTable = new QTableWidget(0, COL_ALTS_COUNT);
configureTable(altsTable);
auto *altsLayout = new QVBoxLayout(altsGroup);
connectTableActivation(altsTable, this, [this](const QModelIndex &index) {
if (!index.isValid()) {
return;
}
auto *userItem = altsTable->item(index.row(), COL_ALTS_USER);
if (userItem != nullptr && !userItem->text().isEmpty()) {
getTabSupervisor()->openTabAlts(userItem->text());
}
});
auto *altsPage = new QWidget;
auto *altsLayout = new QVBoxLayout(altsPage);
altsLayout->addWidget(altsTable);
sessionsGroup = new QGroupBox;
sessionsTable = new QTableWidget(0, COL_SESSIONS_COUNT);
configureTable(sessionsTable);
auto *sessionsLayout = new QVBoxLayout(sessionsGroup);
auto *sessionsPage = new QWidget;
auto *sessionsLayout = new QVBoxLayout(sessionsPage);
sessionsLayout->addWidget(sessionsTable);
staffGroup = new QGroupBox;
staffTable = new QTableWidget(0, COL_STAFF_COUNT);
configureTable(staffTable);
refreshStaffButton = new QPushButton;
@ -162,19 +203,21 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli
auto *staffHeader = new QHBoxLayout;
staffHeader->addStretch();
staffHeader->addWidget(refreshStaffButton);
auto *staffLayout = new QVBoxLayout(staffGroup);
auto *staffPage = new QWidget;
auto *staffLayout = new QVBoxLayout(staffPage);
staffLayout->addWidget(staffTable);
staffLayout->addLayout(staffHeader);
sectionTabs = new QTabWidget;
sectionTabs->addTab(altsPage, QString());
sectionTabs->addTab(sessionsPage, QString());
sectionTabs->addTab(staffPage, QString());
auto *splitter = new QSplitter(Qt::Vertical);
splitter->addWidget(userInfoGroup);
splitter->addWidget(altsGroup);
splitter->addWidget(sessionsGroup);
splitter->addWidget(staffGroup);
splitter->addWidget(sectionTabs);
splitter->setStretchFactor(0, 1);
splitter->setStretchFactor(1, 2);
splitter->setStretchFactor(2, 2);
splitter->setStretchFactor(3, 1);
auto *mainLayout = new QVBoxLayout(centralWidget);
mainLayout->addLayout(topBar);
@ -196,45 +239,56 @@ void TabModeration::retranslateUi()
refreshStaffButton->setText(tr("Refresh"));
userInfoGroup->setTitle(tr("User Info"));
userInfoNameLabel->setText(tr("Name:"));
userInfoRegisteredLabel->setText(tr("Registered:"));
userInfoLastLoginLabel->setText(tr("Last login:"));
userInfoStatusLabel->setText(tr("Status:"));
userInfoCountsLabel->setText(tr("Counts:"));
userInfoNotesLabel->setText(tr("Admin notes:"));
cardReports->setTitle(tr("Reports"));
cardWarns->setTitle(tr("Warnings"));
cardBans->setTitle(tr("Bans"));
altsCard->setTitle(tr("Alts"));
openAltsButton->setText(tr("Open graph"));
openAltsButton->setToolTip(tr("Open this user in the User Alts tab"));
altsGroup->setTitle(tr("Alts"));
sessionsGroup->setTitle(tr("Sessions"));
staffGroup->setTitle(tr("Staff Last Logins"));
sectionTabs->setTabText(0, tr("Alts"));
sectionTabs->setTabText(1, tr("Sessions"));
sectionTabs->setTabText(2, tr("Staff Last Logins"));
if (cachedUserInfoValid) {
applyUserInfo();
}
if (cachedAltsCount >= 0) {
applyAltsCount();
}
altsTable->setHorizontalHeaderLabels({tr("User"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"),
tr("Warns"), tr("Bans"), tr("Active")});
altsTable->setToolTip(tr("Double-click or press Enter on a row to open it in the User Alts tab"));
sessionsTable->setHorizontalHeaderLabels({tr("IP"), tr("Client ID"), tr("Start"), tr("End"), tr("Type")});
staffTable->setHorizontalHeaderLabels({tr("User"), tr("Level"), tr("Last login")});
}
QString TabModeration::formatEpoch(quint64 ts) const
{
if (ts == 0) {
return tr("Unknown");
}
return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString("yyyy-MM-dd HH:mm");
return formatEpochTimestamp(ts, tr("Unknown"));
}
void TabModeration::clearUserData()
{
currentUser.clear();
cachedUserInfoValid = false;
cachedAltsCount = -1;
userInfoNameValue->clear();
userInfoRegisteredValue->clear();
userInfoLastLoginValue->clear();
userInfoStatusValue->clear();
userInfoCountsValue->clear();
sublineLabel->clear();
cardReportsValue->setText(QStringLiteral("–"));
cardWarnsValue->setText(QStringLiteral("–"));
cardBansValue->setText(QStringLiteral("–"));
altsCountValue->setText(QStringLiteral("–"));
userInfoNotesEdit->clear();
altsTable->setRowCount(0);
sessionsTable->setRowCount(0);
resetPasswordButton->setEnabled(false);
removeAvatarButton->setEnabled(false);
removeBannerButton->setEnabled(false);
openAltsButton->setEnabled(false);
}
void TabModeration::investigate(const QString &userName)
@ -253,9 +307,13 @@ void TabModeration::investigateUser()
return;
}
currentUser = userName;
cachedUserInfoValid = false;
cachedAltsCount = -1;
resetPasswordButton->setEnabled(true);
removeAvatarButton->setEnabled(true);
removeBannerButton->setEnabled(true);
openAltsButton->setEnabled(true);
altsCountValue->setText(QStringLiteral("…"));
altsTable->setRowCount(0);
sessionsTable->setRowCount(0);
requestUserInfo(userName);
@ -263,13 +321,22 @@ void TabModeration::investigateUser()
requestAlts(userName);
}
void TabModeration::openAltsGraph()
{
if (currentUser.isEmpty()) {
return;
}
getTabSupervisor()->openTabAlts(currentUser);
}
void TabModeration::requestUserInfo(const QString &userName)
{
userInfoNameValue->setText(userName);
userInfoRegisteredValue->setText(tr("Loading..."));
userInfoLastLoginValue->setText(tr("Loading..."));
sublineLabel->setText(tr("Loading..."));
userInfoStatusValue->clear();
userInfoCountsValue->clear();
cardReportsValue->setText(QStringLiteral("…"));
cardWarnsValue->setText(QStringLiteral("…"));
cardBansValue->setText(QStringLiteral("…"));
userInfoNotesEdit->clear();
Command_ReportUserInfo cmd;
@ -282,9 +349,12 @@ void TabModeration::requestUserInfo(const QString &userName)
void TabModeration::userInfoResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
userInfoRegisteredValue->clear();
userInfoLastLoginValue->clear();
cachedUserInfoValid = false;
sublineLabel->clear();
userInfoStatusValue->setText(tr("Error loading user info."));
cardReportsValue->setText(QStringLiteral("–"));
cardWarnsValue->setText(QStringLiteral("–"));
cardBansValue->setText(QStringLiteral("–"));
return;
}
@ -293,20 +363,28 @@ void TabModeration::userInfoResponse(const Response &response)
return;
}
userInfoRegisteredValue->setText(formatEpoch(resp.registration_time()));
userInfoLastLoginValue->setText(formatEpoch(resp.last_login()));
cachedUserInfo.CopyFrom(resp);
cachedUserInfoValid = true;
applyUserInfo();
}
void TabModeration::applyUserInfo()
{
const Response_ReportUserInfo &resp = cachedUserInfo;
sublineLabel->setText(tr("Registered %1 · Last login %2")
.arg(formatEpoch(resp.registration_time()))
.arg(formatEpoch(resp.last_login())));
QStringList statusParts;
statusParts << (resp.is_active() ? tr("active") : tr("inactive"));
if (resp.has_is_admin() && resp.is_admin()) {
statusParts << tr("admin");
}
userInfoStatusValue->setText(statusParts.join(", "));
userInfoStatusValue->setText(statusParts.join(QStringLiteral(", ")));
userInfoCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3")
.arg(resp.total_reports())
.arg(resp.total_bans())
.arg(resp.total_warns()));
cardReportsValue->setText(QString::number(resp.total_reports()));
cardWarnsValue->setText(QString::number(resp.total_warns()));
cardBansValue->setText(QString::number(resp.total_bans()));
if (resp.has_admin_notes() && !resp.admin_notes().empty()) {
userInfoNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes()));
@ -315,12 +393,21 @@ void TabModeration::userInfoResponse(const Response &response)
}
}
void TabModeration::applyAltsCount()
{
altsCountValue->setText(tr("%1 related").arg(cachedAltsCount));
}
void TabModeration::requestSessions(const QString &userName)
{
Command_GetUserSessions cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::sessionsResponse);
connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) {
if (userName == currentUser) {
sessionsResponse(response);
}
});
client->sendCommand(pend);
}
@ -352,7 +439,11 @@ void TabModeration::requestAlts(const QString &userName)
Command_GetUserAlts cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::altsResponse);
connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) {
if (userName == currentUser) {
altsResponse(response);
}
});
client->sendCommand(pend);
}
@ -360,10 +451,21 @@ void TabModeration::altsResponse(const Response &response)
{
altsTable->setRowCount(0);
if (response.response_code() != Response::RespOk) {
cachedAltsCount = -1;
altsCountValue->setText(QStringLiteral("–"));
return;
}
const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext);
// The server always includes the frontier user as a depth-0 seed row.
int relatedCount = 0;
for (int i = 0; i < resp.alts_size(); ++i) {
if (!resp.alts(i).has_depth() || resp.alts(i).depth() > 0) {
++relatedCount;
}
}
cachedAltsCount = relatedCount;
applyAltsCount();
altsTable->setRowCount(resp.alts_size());
for (int i = 0; i < resp.alts_size(); ++i) {
const ServerInfo_UserAlt &alt = resp.alts(i);

View file

@ -5,6 +5,7 @@
#include <QString>
#include <QtGlobal>
#include <libcockatrice/protocol/pb/response_report_user_info.pb.h>
#include <qtmetamacros.h>
class AbstractClient;
@ -13,6 +14,7 @@ class QLabel;
class QLineEdit;
class QPushButton;
class QTableWidget;
class QTabWidget;
class QTextEdit;
class Response;
class TabSupervisor;
@ -36,6 +38,7 @@ public:
private slots:
void investigateUser();
void openAltsGraph();
void userInfoResponse(const Response &response);
void sessionsResponse(const Response &response);
void altsResponse(const Response &response);
@ -53,10 +56,15 @@ private:
void requestAlts(const QString &userName);
void requestModeratorLogins();
void clearUserData();
void applyUserInfo();
void applyAltsCount();
[[nodiscard]] QString formatEpoch(quint64 ts) const;
AbstractClient *client;
QString currentUser;
Response_ReportUserInfo cachedUserInfo;
bool cachedUserInfoValid = false;
int cachedAltsCount = -1;
QLineEdit *searchEdit;
QPushButton *investigateButton;
@ -65,25 +73,25 @@ private:
QPushButton *removeBannerButton;
QGroupBox *userInfoGroup;
QLabel *userInfoNameLabel;
QLabel *userInfoNameValue;
QLabel *userInfoRegisteredLabel;
QLabel *userInfoRegisteredValue;
QLabel *userInfoLastLoginLabel;
QLabel *userInfoLastLoginValue;
QLabel *userInfoStatusLabel;
QLabel *userInfoStatusValue;
QLabel *userInfoCountsLabel;
QLabel *userInfoCountsValue;
QLabel *sublineLabel;
QGroupBox *cardReports;
QGroupBox *cardWarns;
QGroupBox *cardBans;
QGroupBox *altsCard;
QLabel *cardReportsValue;
QLabel *cardWarnsValue;
QLabel *cardBansValue;
QLabel *altsCountValue;
QLabel *userInfoNotesLabel;
QTextEdit *userInfoNotesEdit;
QGroupBox *altsGroup;
QTabWidget *sectionTabs;
QTableWidget *altsTable;
QGroupBox *sessionsGroup;
QTableWidget *sessionsTable;
QGroupBox *staffGroup;
QTableWidget *staffTable;
QPushButton *openAltsButton;
QPushButton *refreshStaffButton;
};

View file

@ -5,6 +5,7 @@
#include "../utility/report_utils.h"
#include "abstract_client.h"
#include "libcockatrice/protocol/pb/response.pb.h"
#include "report_status_delegate.h"
#include "tab_supervisor.h"
#include <QAbstractItemView>
@ -24,7 +25,7 @@
#include <QPushButton>
#include <QSharedPointer>
#include <QSignalBlocker>
#include <QSplitter>
#include <QTabWidget>
#include <QTableWidget>
#include <QTableWidgetItem>
#include <QTextCursor>
@ -126,26 +127,43 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged);
table->setContextMenuPolicy(Qt::CustomContextMenu);
connect(table, &QTableWidget::customContextMenuRequested, this, &TabReport::showTableContextMenu);
table->setItemDelegateForColumn(COL_STATUS, new ReportStatusDelegate(table));
userContextMenu = new UserContextMenu(tabSupervisor, this);
connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &TabReport::openMessageDialog);
descGroup = new QGroupBox;
reportHeaderName = new QLabel;
QFont displayFont = reportHeaderName->font();
displayFont.setPointSize(displayFont.pointSize() + 6);
displayFont.setBold(true);
reportHeaderName->setFont(displayFont);
reportHeaderStatus = new QLabel;
QFont statusFont = reportHeaderStatus->font();
statusFont.setBold(true);
reportHeaderStatus->setFont(statusFont);
reportHeaderSubline = new QLabel;
auto *reportIdentityRow = new QHBoxLayout;
reportIdentityRow->addWidget(reportHeaderName);
reportIdentityRow->addStretch();
reportIdentityRow->addWidget(reportHeaderStatus);
auto *descPage = new QWidget;
descriptionEdit = new QTextEdit;
descriptionEdit->setReadOnly(true);
auto *descLayout = new QVBoxLayout(descGroup);
auto *descLayout = new QVBoxLayout(descPage);
descLayout->setContentsMargins(4, 4, 4, 4);
descLayout->addWidget(descriptionEdit);
chatGroup = new QGroupBox;
auto *chatPage = new QWidget;
chatLogEdit = new QTextEdit;
chatLogEdit->setReadOnly(true);
applyChatLogFont();
connect(themeManager, &ThemeManager::fontsChanged, this, &TabReport::applyChatLogFont);
auto *chatLayout = new QVBoxLayout(chatGroup);
auto *chatLayout = new QVBoxLayout(chatPage);
chatLayout->setContentsMargins(4, 4, 4, 4);
chatLayout->addWidget(chatLogEdit);
commentsGroup = new QGroupBox;
auto *commentsPage = new QWidget;
commentsEdit = new QTextEdit;
commentsEdit->setReadOnly(true);
auto *commentInputRow = new QHBoxLayout;
@ -156,12 +174,12 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
connect(commentInput, &QLineEdit::returnPressed, this, &TabReport::addComment);
commentInputRow->addWidget(commentInput);
commentInputRow->addWidget(commentButton);
auto *commentsLayout = new QVBoxLayout(commentsGroup);
auto *commentsLayout = new QVBoxLayout(commentsPage);
commentsLayout->setContentsMargins(4, 4, 4, 4);
commentsLayout->addWidget(commentsEdit);
commentsLayout->addLayout(commentInputRow);
userContextGroup = new QGroupBox;
auto *userPage = new QWidget;
userContextName = new QLabel;
userContextAccountAge = new QLabel;
userContextReports = new QLabel;
@ -180,7 +198,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
userContextWarnsLabel = new QLabel;
userContextNotesLabel = new QLabel;
userContextRecentReportsLabel = new QLabel;
auto *ucLayout = new QGridLayout(userContextGroup);
auto *ucLayout = new QGridLayout(userPage);
ucLayout->setContentsMargins(8, 8, 8, 8);
ucLayout->addWidget(userContextUserLabel, 0, 0);
ucLayout->addWidget(userContextName, 0, 1, 1, 3);
@ -196,7 +214,6 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
ucLayout->addWidget(userContextNotes, 3, 1, 1, 3);
ucLayout->addWidget(userContextRecentReportsLabel, 4, 0, 1, 4);
ucLayout->addWidget(userContextRecentReports, 5, 0, 1, 4);
userContextGroup->setVisible(false);
statsGroup = new QGroupBox;
statsGroup->setCheckable(true);
@ -223,13 +240,11 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
}
});
detailSplitter = new QSplitter(Qt::Vertical);
detailSplitter->addWidget(descGroup);
detailSplitter->addWidget(chatGroup);
detailSplitter->addWidget(commentsGroup);
detailSplitter->setStretchFactor(0, 1);
detailSplitter->setStretchFactor(1, 1);
detailSplitter->setStretchFactor(2, 2);
detailTabs = new QTabWidget;
detailTabs->addTab(descPage, QString());
detailTabs->addTab(chatPage, QString());
detailTabs->addTab(commentsPage, QString());
detailTabs->addTab(userPage, QString());
assignButton = new QPushButton;
resolveButton = new QPushButton;
@ -261,12 +276,14 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
layout->addLayout(topBar);
layout->addWidget(statsLabel);
layout->addWidget(table, 2);
layout->addWidget(detailSplitter, 1);
layout->addWidget(userContextGroup);
layout->addLayout(reportIdentityRow);
layout->addWidget(reportHeaderSubline);
layout->addWidget(detailTabs, 2);
layout->addWidget(statsGroup);
layout->addLayout(actionBar);
retranslateUi();
clearReportHeader();
setActionsEnabled(false);
refreshList();
@ -293,18 +310,18 @@ void TabReport::retranslateUi()
table->setHorizontalHeaderLabels({tr("#"), tr("Time"), tr("Reporter"), tr("Reported User"), tr("Category"),
tr("Game ID"), tr("Status"), tr("Assigned To"), tr("Replay"), tr("Room")});
descGroup->setTitle(tr("Description"));
chatGroup->setTitle(tr("Chat Log Context"));
chatGroup->setToolTip(
tr("Chat log attached by the reporter. It is captured from their client and may be incomplete or edited."));
commentsGroup->setTitle(tr("Comments / Thread"));
detailTabs->setTabText(0, tr("Description"));
detailTabs->setTabText(1, tr("Chat Log Context"));
detailTabs->setTabToolTip(1, tr("Chat log attached by the reporter. It is captured from their client and may be "
"incomplete or edited."));
detailTabs->setTabText(2, tr("Comments / Thread"));
detailTabs->setTabText(3, tr("Reported User"));
descriptionEdit->setPlaceholderText(tr("No description."));
chatLogEdit->setPlaceholderText(tr("No chat log attached."));
commentsEdit->setPlaceholderText(tr("No comments yet."));
commentInput->setPlaceholderText(tr("Type a reply..."));
commentButton->setText(tr("Send"));
userContextGroup->setTitle(tr("Reported User Context"));
userContextUserLabel->setText(tr("User:"));
userContextAgeLabel->setText(tr("Account Age:"));
userContextReportsLabel->setText(tr("Reports:"));
@ -312,10 +329,18 @@ void TabReport::retranslateUi()
userContextWarnsLabel->setText(tr("Warns:"));
userContextNotesLabel->setText(tr("Admin Notes:"));
userContextRecentReportsLabel->setText(tr("Recent Reports Against User:"));
userContextAccountAge->setText(QString());
userContextReports->setText(QString());
userContextBans->setText(QString());
userContextWarns->setText(QString());
if (headerReportId >= 0) {
for (const ServerInfo_Report &r : filteredReports) {
if (r.report_id() == headerReportId) {
updateReportHeader(r);
const QString status = QString::fromStdString(r.status());
const bool canComment = (status == "open" || status == "assigned");
commentInput->setPlaceholderText(canComment ? tr("Type a reply...") : tr("This report is closed."));
break;
}
}
}
statsGroup->setTitle(tr("Report Statistics"));
@ -408,7 +433,8 @@ void TabReport::reportListResponse(const Response &response)
chatLogEdit->clear();
commentsEdit->clear();
setActionsEnabled(false);
userContextGroup->setVisible(false);
clearReportHeader();
clearUserContext();
}
}
@ -445,11 +471,14 @@ void TabReport::applyFilters()
table->setSortingEnabled(false);
table->setRowCount(filteredReports.size());
QFont reportedFont = table->font();
reportedFont.setBold(true);
for (int row = 0; row < filteredReports.size(); ++row) {
const ServerInfo_Report &r = filteredReports[row];
report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID,
COL_STATUS, COL_ASSIGNED);
COL_STATUS, COL_ASSIGNED, false);
table->setItem(row, COL_REPORTER, new QTableWidgetItem(QString::fromStdString(r.reporter_name())));
@ -458,12 +487,30 @@ void TabReport::applyFilters()
table->setItem(row, COL_ROOM,
new QTableWidgetItem(r.has_room_id() && r.room_id() > 0 ? QString::number(r.room_id()) : ""));
if (auto *reportedItem = table->item(row, COL_REPORTED)) {
reportedItem->setFont(reportedFont);
}
}
table->setSortingEnabled(true);
table->resizeColumnsToContents();
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
if (headerReportId >= 0) {
bool stillVisible = false;
for (const ServerInfo_Report &r : filteredReports) {
if (r.report_id() == headerReportId) {
stillVisible = true;
break;
}
}
if (!stillVisible) {
clearReportHeader();
clearUserContext();
}
}
statusLabel->setText(tr("%1 report(s)").arg(filteredReports.size()));
}
@ -490,6 +537,31 @@ void TabReport::updateStats()
.arg(dismissed));
}
void TabReport::updateReportHeader(const ServerInfo_Report &report)
{
reportHeaderName->setText(QString::fromStdString(report.reported_user_name()));
reportHeaderStatus->setText(report_utils::formatReportStatus(QString::fromStdString(report.status())));
QStringList parts;
parts << QString::fromStdString(report.category());
parts << QDateTime::fromSecsSinceEpoch(report.report_time()).toLocalTime().toString("yyyy-MM-dd HH:mm");
if (report.has_room_id() && report.room_id() > 0) {
parts << tr("Room %1").arg(report.room_id());
}
if (report.game_id() > 0) {
parts << tr("Game %1").arg(report.game_id());
}
reportHeaderSubline->setText(parts.join(QStringLiteral(" · ")));
}
void TabReport::clearReportHeader()
{
headerReportId = -1;
reportHeaderName->setText(tr("Select a report to inspect."));
reportHeaderStatus->clear();
reportHeaderSubline->clear();
}
void TabReport::onSelectionChanged()
{
const int row = table->currentRow();
@ -500,7 +572,8 @@ void TabReport::onSelectionChanged()
commentInput->clear();
commentButton->setEnabled(false);
setActionsEnabled(false);
userContextGroup->setVisible(false);
clearReportHeader();
clearUserContext();
return;
}
@ -509,6 +582,18 @@ void TabReport::onSelectionChanged()
updateActionStates();
}
void TabReport::clearUserContext()
{
lastRequestedUser.clear();
userContextName->setText(tr("—"));
userContextAccountAge->clear();
userContextReports->clear();
userContextBans->clear();
userContextWarns->clear();
userContextNotes->setPlainText(tr("Select a report to inspect."));
userContextRecentReports->clear();
}
void TabReport::showTableContextMenu(const QPoint &pos)
{
QTableWidgetItem *clickedItem = table->itemAt(pos);
@ -546,12 +631,17 @@ void TabReport::loadReportDetails(int reportId)
for (const ServerInfo_Report &r : filteredReports) {
if (r.report_id() == reportId) {
descriptionEdit->setPlainText(QString::fromStdString(r.description()));
updateReportHeader(r);
if (headerReportId != reportId) {
headerReportId = reportId;
detailTabs->setCurrentIndex(0);
}
QString reportedUser = QString::fromStdString(r.reported_user_name());
if (!reportedUser.isEmpty()) {
requestUserInfo(reportedUser);
} else {
userContextGroup->setVisible(false);
clearUserContext();
}
QString status = QString::fromStdString(r.status());
@ -871,12 +961,11 @@ void TabReport::addCommentResponse(const Response &response)
void TabReport::requestUserInfo(const QString &userName)
{
if (userName.isEmpty()) {
userContextGroup->setVisible(false);
clearUserContext();
return;
}
lastRequestedUser = userName;
userContextGroup->setVisible(true);
userContextName->setText(userName);
userContextAccountAge->setText(tr("Loading..."));
userContextReports->clear();

View file

@ -16,8 +16,8 @@ class QGroupBox;
class QLabel;
class QLineEdit;
class QPushButton;
class QSplitter;
class QTableWidget;
class QTabWidget;
class QTextEdit;
class QTimer;
class GameReplay;
@ -72,6 +72,9 @@ private:
void applyFilters();
void updateStats();
void loadReportDetails(int reportId);
void updateReportHeader(const ServerInfo_Report &report);
void clearReportHeader();
void clearUserContext();
AbstractClient *client;
@ -81,10 +84,10 @@ private:
QPushButton *refreshButton;
QLabel *statsLabel;
QTableWidget *table;
QSplitter *detailSplitter;
QGroupBox *descGroup;
QGroupBox *chatGroup;
QGroupBox *commentsGroup;
QLabel *reportHeaderName;
QLabel *reportHeaderStatus;
QLabel *reportHeaderSubline;
QTabWidget *detailTabs;
QTextEdit *descriptionEdit;
QTextEdit *chatLogEdit;
QTextEdit *commentsEdit;
@ -100,7 +103,6 @@ private:
QTimer *refreshTimer;
UserContextMenu *userContextMenu;
QGroupBox *userContextGroup;
QLabel *userContextName;
QLabel *userContextAccountAge;
QLabel *userContextReports;
@ -125,6 +127,7 @@ private:
QList<ServerInfo_Report> allReports;
QList<ServerInfo_Report> filteredReports;
int headerReportId = -1;
int detailsRequestedReportId = -1;
int detailsRequestSeq = 0;
int selectedReportIdBeforeRefresh = -1;

View file

@ -22,6 +22,7 @@
#include "tab.h"
#include "tab_account.h"
#include "tab_admin.h"
#include "tab_alts.h"
#include "tab_card_art_rules.h"
#include "tab_deck_editor.h"
#include "tab_deck_storage.h"
@ -157,7 +158,7 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *
: QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr),
tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr),
tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr),
tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false)
tabModeration(nullptr), tabAlts(nullptr), tabDeveloper(nullptr), isLocalGame(false)
{
setObjectName("tabSupervisor");
@ -575,6 +576,7 @@ void TabSupervisor::startLocal(const QList<AbstractClient *> &_clients)
tabLog = nullptr;
tabReport = nullptr;
tabModeration = nullptr;
tabAlts = nullptr;
tabDeveloper = nullptr;
isLocalGame = true;
userInfo = new ServerInfo_User;
@ -623,6 +625,9 @@ void TabSupervisor::stop()
if (tabModeration) {
tabModeration->close();
}
if (tabAlts) {
tabAlts->close();
}
if (tabCardArtRules) {
tabCardArtRules->close();
}
@ -931,6 +936,21 @@ void TabSupervisor::openTabModeration(const QString &userName)
aTabModeration->setChecked(true);
}
void TabSupervisor::openTabAlts(const QString &userName)
{
if (tabAlts) {
setCurrentWidget(tabAlts);
if (!userName.isEmpty()) {
tabAlts->investigate(userName);
}
return;
}
tabAlts = new TabAlts(this, client, userName);
myAddTab(tabAlts);
setCurrentWidget(tabAlts);
connect(tabAlts, &QObject::destroyed, this, [this] { tabAlts = nullptr; });
}
void TabSupervisor::actTabDeveloper(bool checked)
{
if (checked && !tabDeveloper) {

View file

@ -50,6 +50,7 @@ class TabAdmin;
class TabMessage;
class TabReport;
class TabModeration;
class TabAlts;
class TabAccount;
class TabDeckEditor;
class TabDeveloper;
@ -116,6 +117,7 @@ private:
TabLog *tabLog;
TabReport *tabReport;
TabModeration *tabModeration;
TabAlts *tabAlts;
TabDeveloper *tabDeveloper;
QMap<int, TabRoom *> roomTabs;
QMap<int, TabGame *> gameTabs;
@ -209,6 +211,7 @@ public slots:
/** Joins or spectates a game listed on a user's profile hover card. */
void joinGameFromUserCard(int gameId, int roomId, bool asSpectator);
void openTabModeration(const QString &userName = {});
void openTabAlts(const QString &userName = {});
void switchToFirstAvailableNetworkTab();
void maximizeMainWindow();
void actTabVisualDeckStorage(bool checked);

View file

@ -0,0 +1,27 @@
/**
* @file epoch_format.h
* @brief Shared timestamp formatting for the moderation tables.
*/
#ifndef EPOCH_FORMAT_H
#define EPOCH_FORMAT_H
#include <QDateTime>
#include <QString>
/**
* @brief Format a unix timestamp the way the moderation tabs show dates.
*
* @param ts Seconds since the epoch; 0 means "unknown".
* @param unknownText Label returned for 0, translated by the caller so the
* translation context stays with the tab.
*/
inline QString formatEpochTimestamp(quint64 ts, const QString &unknownText)
{
if (ts == 0) {
return unknownText;
}
return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString(QStringLiteral("yyyy-MM-dd HH:mm"));
}
#endif // EPOCH_FORMAT_H

View file

@ -0,0 +1,41 @@
#include "pill_painter.h"
#include <QApplication>
#include <QFontMetrics>
#include <QPainter>
#include <QRect>
#include <QStyle>
#include <QStyleOptionViewItem>
namespace
{
constexpr qreal PILL_ALPHA = 0.30;
} // namespace
bool pill_painter::paint(QPainter *painter,
const QStyleOptionViewItem &option,
QPalette::ColorRole tintRole,
QPalette::ColorRole textRole,
const QString &text)
{
const QRect pillRect = option.rect.adjusted(PAD_X / 2, PAD_Y / 2, -PAD_X / 2, -PAD_Y / 2);
if (pillRect.width() <= 0 || pillRect.height() <= 0) {
return false;
}
if ((option.state & QStyle::State_Selected) == 0) {
painter->save();
painter->setRenderHint(QPainter::Antialiasing, true);
QColor pill = option.palette.color(tintRole);
pill.setAlphaF(PILL_ALPHA);
painter->setPen(Qt::NoPen);
painter->setBrush(pill);
painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0);
painter->restore();
}
QStyle *style = option.widget != nullptr ? option.widget->style() : QApplication::style();
const QString elided = option.fontMetrics.elidedText(text, option.textElideMode, pillRect.width());
style->drawItemText(painter, pillRect, Qt::AlignCenter, option.palette, true, elided, textRole);
return true;
}

View file

@ -0,0 +1,29 @@
#ifndef PILL_PAINTER_H
#define PILL_PAINTER_H
#include <QPalette>
#include <QString>
class QPainter;
class QStyleOptionViewItem;
namespace pill_painter
{
constexpr int PAD_X = 10;
constexpr int PAD_Y = 4;
/**
* Draws a translucent, palette-tinted rounded pill inside the option's rect and
* paints the text elided and centred on it, so badges follow the active theme.
* Returns false when the cell is too small to hold a pill, leaving the caller to
* fall back to default painting.
*/
bool paint(QPainter *painter,
const QStyleOptionViewItem &option,
QPalette::ColorRole tintRole,
QPalette::ColorRole textRole,
const QString &text);
} // namespace pill_painter
#endif // PILL_PAINTER_H

View file

@ -80,6 +80,23 @@ QString formatReportTime(qint64 secondsSinceEpoch)
return dt.toString("yyyy-MM-dd hh:mm");
}
QString formatReportStatus(const QString &status)
{
if (status == QStringLiteral("open")) {
return QObject::tr("Open");
}
if (status == QStringLiteral("assigned")) {
return QObject::tr("Assigned");
}
if (status == QStringLiteral("resolved")) {
return QObject::tr("Resolved");
}
if (status == QStringLiteral("dismissed")) {
return QObject::tr("Dismissed");
}
return status;
}
void fillReportTableRow(QTableWidget *table,
int row,
const ServerInfo_Report &report,
@ -89,7 +106,8 @@ void fillReportTableRow(QTableWidget *table,
int categoryColumn,
int gameIdColumn,
int statusColumn,
int assignedColumn)
int assignedColumn,
bool colorizeStatus)
{
auto *idItem = new QTableWidgetItem(QString::number(report.report_id()));
idItem->setData(Qt::UserRole, report.report_id());
@ -102,8 +120,12 @@ void fillReportTableRow(QTableWidget *table,
table->setItem(row, gameIdColumn,
new QTableWidgetItem(report.game_id() > 0 ? QString::number(report.game_id()) : QString()));
auto *statusItem = new QTableWidgetItem(QString::fromStdString(report.status()));
setReportStatusItemColor(statusItem, QString::fromStdString(report.status()));
const QString rawStatus = QString::fromStdString(report.status());
auto *statusItem = new QTableWidgetItem(formatReportStatus(rawStatus));
statusItem->setData(Qt::UserRole, rawStatus);
if (colorizeStatus) {
setReportStatusItemColor(statusItem, rawStatus);
}
table->setItem(row, statusColumn, statusItem);
table->setItem(row, assignedColumn, new QTableWidgetItem(QString::fromStdString(report.assigned_mod_name())));

View file

@ -20,6 +20,7 @@ QFont reportLogFont();
QString formatReportCategory(const QString &category);
QString formatReportTime(qint64 secondsSinceEpoch);
QString formatReportStatus(const QString &status);
void fillReportTableRow(QTableWidget *table,
int row,
@ -30,7 +31,8 @@ void fillReportTableRow(QTableWidget *table,
int categoryColumn,
int gameIdColumn,
int statusColumn,
int assignedColumn);
int assignedColumn,
bool colorizeStatus = true);
void renderReportDetails(QTextEdit *chatLogEdit,
QTextEdit *commentsEdit,

View file

@ -0,0 +1,28 @@
#include "table_activation.h"
#include <QKeySequence>
#include <QShortcut>
#include <QTableWidget>
namespace
{
void addActivationShortcut(QTableWidget *table,
QObject *context,
int key,
const std::function<void(const QModelIndex &)> &handler)
{
auto *shortcut = new QShortcut(QKeySequence(key), table);
// The table's viewport takes focus, so match children as well.
shortcut->setContext(Qt::WidgetWithChildrenShortcut);
QObject::connect(shortcut, &QShortcut::activated, context, [table, handler] { handler(table->currentIndex()); });
}
} // namespace
void connectTableActivation(QTableWidget *table,
QObject *context,
const std::function<void(const QModelIndex &)> &handler)
{
QObject::connect(table, &QTableWidget::doubleClicked, context, handler);
addActivationShortcut(table, context, Qt::Key_Return, handler);
addActivationShortcut(table, context, Qt::Key_Enter, handler);
}

View file

@ -0,0 +1,32 @@
/**
* @file table_activation.h
* @brief Row activation for tables that must not navigate on a single click.
*/
#ifndef TABLE_ACTIVATION_H
#define TABLE_ACTIVATION_H
#include <functional>
class QModelIndex;
class QObject;
class QTableWidget;
/**
* @brief Call @p handler on double-click or Enter, never on a single click.
*
* QAbstractItemView::activated fires on a single click on platforms where
* SH_ItemView_ActivateItemOnSingleClick is set, so navigation actions must not
* use it. This connects doubleClicked and installs Return/Enter shortcuts that
* forward the current index.
*
* @param table The table whose rows activate.
* @param context Object that owns the connections; the handler is disconnected
* with it.
* @param handler Receives the activated row's index, which may be invalid.
*/
void connectTableActivation(QTableWidget *table,
QObject *context,
const std::function<void(const QModelIndex &)> &handler);
#endif // TABLE_ACTIVATION_H

View file

@ -15,9 +15,23 @@
#include <QtGlobal>
#include <libcockatrice/filters/filter_tree.h>
namespace
{
/** @brief Remove the filters that apply to the given color and attribute from the given model. */
static void removeFilters(FilterTreeModel *filterModel, QChar color, CardFilter::Attr attr)
{
for (const CardFilter *filter : filterModel->getFiltersOfType(attr)) {
if (filter->term() == color) {
filterModel->removeFilter(filter);
}
}
}
} // namespace
/**
* This widget provides a graphical control element for the CardFilter::Attr::AttrColor filters applied to the filter
* model.
* This widget provides a graphical control element for the CardFilter::Attr::AttrColor and
* CardFilter::Attr::AttrColorIdentity filters applied to the filter model.
* @param parent The Qt Widget that this widget will parent to
* @param _filterModel The filter model that this widget will manipulate
*/
@ -59,11 +73,25 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi()
modeComboBox->blockSignals(true);
modeComboBox->clear();
modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch));
modeComboBox->addItem(tr("Includes"), QVariant::fromValue(FilterMode::Includes));
modeComboBox->addItem(tr("Excludes"), QVariant::fromValue(FilterMode::Excludes));
modeComboBox->addItem(tr("Include / Exclude"), QVariant::fromValue(FilterMode::IncludeExclude));
modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch));
modeComboBox->addItem(tr("Color identity"), QVariant::fromValue(FilterMode::ColorIdentity));
modeComboBox->setItemData(0, tr("Cards containing any of the selected colors"), Qt::ToolTipRole);
modeComboBox->setItemData(1, tr("Cards containing none of the selected colors"), Qt::ToolTipRole);
modeComboBox->setItemData(2, tr("Cards containing any of the selected colors and none of the unselected colors"),
Qt::ToolTipRole);
modeComboBox->setItemData(3, tr("Cards being exactly of the selected colors"), Qt::ToolTipRole);
modeComboBox->setItemData(4, tr("Cards whose color identity fits within the selected colors"), Qt::ToolTipRole);
if (currentMode == FilterMode::Custom) {
addCustomFilterModeItem();
}
modeComboBox->setToolTip(tr("How selected and unselected colors are combined in the filter"));
modeComboBox->setAccessibleName(tr("Color filter mode"));
// Restore current mode
const int index = modeComboBox->findData(QVariant::fromValue(currentMode));
@ -74,6 +102,28 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi()
modeComboBox->blockSignals(false);
}
void VisualDatabaseDisplayColorFilterWidget::setFilterMode(FilterMode filterMode)
{
int index = modeComboBox->findData(QVariant::fromValue(filterMode));
if (index < 0 && filterMode == FilterMode::Custom) {
index = modeComboBox->count();
addCustomFilterModeItem();
}
if (index >= 0) {
modeComboBox->setCurrentIndex(index);
}
}
void VisualDatabaseDisplayColorFilterWidget::addCustomFilterModeItem()
{
int customIndex = modeComboBox->count();
modeComboBox->addItem(tr("Custom"), QVariant::fromValue(FilterMode::Custom));
modeComboBox->setItemData(customIndex, tr("Cards matching an unknown combination of color filters"),
Qt::ToolTipRole);
}
void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, bool active)
{
if (active) {
@ -85,74 +135,24 @@ void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, boo
void VisualDatabaseDisplayColorFilterWidget::addFilter(QChar color)
{
QString colorString = color;
QString typeStr;
FilterModeInfo info = getFilterModeInfo(currentMode);
// Remove previous filters
removeFilters(filterModel, color, info.attr);
QList<const CardFilter *> allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor);
QList<const CardFilter *> matchingFilters;
for (const CardFilter *filter : allColorFilters) {
if (filter->term() == color) {
matchingFilters.append(filter);
}
}
for (const CardFilter *filter : matchingFilters) {
filterModel->removeFilter(filter);
}
// Add actual filter
switch (currentMode) {
case FilterMode::ExactMatch:
filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeAnd, CardFilter::Attr::AttrColor));
break;
case FilterMode::Includes:
filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor));
break;
case FilterMode::IncludeExclude:
filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor));
break;
if (info.includeType != CardFilter::Type::TypeEnd) {
filterModel->addFilter(new CardFilter(color, info.includeType, info.attr));
}
}
void VisualDatabaseDisplayColorFilterWidget::removeFilter(QChar color)
{
QString colorString = color;
FilterModeInfo info = getFilterModeInfo(currentMode);
// Remove inclusion filters
QList<const CardFilter *> allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor);
QList<const CardFilter *> matchingFilters;
removeFilters(filterModel, color, info.attr);
for (const CardFilter *filter : allColorFilters) {
if (filter->term() == color) {
matchingFilters.append(filter);
}
}
for (const CardFilter *filter : matchingFilters) {
filterModel->removeFilter(filter);
}
// Add exclusion filters if the mode demands it
switch (currentMode) {
case FilterMode::ExactMatch:
filterModel->addFilter(
new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor));
break;
case FilterMode::IncludeExclude:
filterModel->addFilter(
new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor));
break;
case FilterMode::Includes:
// No exclusion in Includes mode
break;
// Add exclusion filters if the mode demands it.
if (info.excludeType != CardFilter::Type::TypeEnd) {
filterModel->addFilter(new CardFilter(color, info.excludeType, info.attr));
}
}
@ -169,6 +169,7 @@ void VisualDatabaseDisplayColorFilterWidget::updateFilterMode()
filterModel->filterTree()->blockSignals(true);
filterModel->clearFiltersOfType(CardFilter::Attr::AttrColor);
filterModel->clearFiltersOfType(CardFilter::Attr::AttrColorIdentity);
const QList<ManaSymbolWidget *> manaSymbolWidgets = findChildren<ManaSymbolWidget *>();
@ -211,52 +212,53 @@ QList<QChar> VisualDatabaseDisplayColorFilterWidget::getActiveColors()
void VisualDatabaseDisplayColorFilterWidget::syncWithFilterModel()
{
QList<const CardFilter *> allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor);
QList<const CardFilter *> filters;
filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColor));
filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColorIdentity));
QList<ManaSymbolWidget *> manaSymbolWidgets = findChildren<ManaSymbolWidget *>();
// Re-derive the mode from the filters in the model, so that a loaded filter set selects the mode that
// produced it (or Custom when none could have).
currentMode = deriveFilterMode(filters, currentMode);
for (ManaSymbolWidget *manaSymbolWidget : manaSymbolWidgets) {
// Update the combobox. Add a Custom item only if we have a custom filter mode.
int customIndex = modeComboBox->findData(QVariant::fromValue(FilterMode::Custom));
modeComboBox->blockSignals(true);
if (currentMode == FilterMode::Custom) {
if (customIndex < 0) {
customIndex = modeComboBox->count();
addCustomFilterModeItem();
}
modeComboBox->setCurrentIndex(customIndex);
} else {
if (customIndex >= 0) {
modeComboBox->removeItem(customIndex);
}
int index = modeComboBox->findData(QVariant::fromValue(currentMode));
if (index >= 0) {
modeComboBox->setCurrentIndex(index);
}
}
modeComboBox->blockSignals(false);
// Update the mana symbol widgets by looking for the current mode's include or exclude filters.
const FilterModeInfo modeInfo = getFilterModeInfo(currentMode);
const bool useExclude =
modeInfo.includeType == CardFilter::Type::TypeEnd && modeInfo.excludeType != CardFilter::Type::TypeEnd;
const CardFilter::Type filterType = useExclude ? modeInfo.excludeType : modeInfo.includeType;
filters = filterModel->getFiltersOfType(modeInfo.attr);
for (ManaSymbolWidget *manaSymbolWidget : findChildren<ManaSymbolWidget *>()) {
bool found = false;
for (const CardFilter *filter : allColorFilters) {
if (manaSymbolWidget->getSymbolChar() == filter->term()) {
switch (currentMode) {
case FilterMode::ExactMatch:
switch (filter->type()) {
case CardFilter::Type::TypeAnd:
setManaSymbolActive(filter->term().at(0), true);
break;
default:
setManaSymbolActive(filter->term().at(0), false);
break;
}
break;
case FilterMode::Includes:
switch (filter->type()) {
case CardFilter::Type::TypeOr:
setManaSymbolActive(filter->term().at(0), true);
break;
default:
setManaSymbolActive(filter->term().at(0), false);
break;
}
break;
case FilterMode::IncludeExclude:
switch (filter->type()) {
case CardFilter::Type::TypeOr:
setManaSymbolActive(filter->term().at(0), true);
break;
default:
setManaSymbolActive(filter->term().at(0), false);
break;
}
break;
}
for (const CardFilter *filter : filters) {
if (filter->type() == filterType && manaSymbolWidget->getSymbolChar() == filter->term()) {
found = true;
break;
}
}
if (!found) {
setManaSymbolActive(manaSymbolWidget->getSymbolChar(), false);
}
manaSymbolWidget->setColorActive(useExclude ? !found : found);
manaSymbolWidget->setDisabled(currentMode == FilterMode::Custom);
}
}

View file

@ -10,8 +10,8 @@
#include <QByteArray>
#include <QChar>
#include <QList>
#include <QMetaType>
#include <QWidget>
#include <libcockatrice/filters/filter_mode.h>
#include <libcockatrice/utility/debouncer.h>
#include <qtmetamacros.h>
@ -38,15 +38,6 @@ private:
int circleDiameter;
};
enum class FilterMode
{
ExactMatch, // Only selected colors are included, all others are excluded.
Includes, // Include selected colors (OR condition).
IncludeExclude // Include selected colors (OR) and exclude unselected colors (AND NOT).
};
Q_DECLARE_METATYPE(FilterMode)
class VisualDatabaseDisplayColorFilterWidget : public QWidget
{
Q_OBJECT
@ -54,11 +45,13 @@ class VisualDatabaseDisplayColorFilterWidget : public QWidget
public:
explicit VisualDatabaseDisplayColorFilterWidget(QWidget *parent, FilterTreeModel *filterModel);
void retranslateUi();
void setFilterMode(FilterMode filterMode);
signals:
void filterModeChanged(FilterMode filterMode);
private slots:
void addCustomFilterModeItem();
void handleColorToggled(QChar color, bool active);
void addFilter(QChar color);
void removeFilter(QChar color);

View file

@ -160,6 +160,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent,
connect(clearFilterWidget, &QToolButton::clicked, this, [this] {
filterModel->blockSignals(true);
filterModel->filterTree()->blockSignals(true);
colorFilterWidget->setFilterMode(FilterMode::Includes);
filterModel->clear();
filterModel->blockSignals(false);
filterModel->filterTree()->blockSignals(false);

View file

@ -10,7 +10,7 @@
#include <QElapsedTimer>
#include <QFile>
#include <QFileInfo>
#include <QFutureWatcher>
#include <QFuture>
#include <QMetaObject>
#include <QMetaType>
#include <QSet>
@ -244,15 +244,15 @@ void VisualDeckStorageModel::startScan()
// The scan (directory walk + one stat per file) runs on a worker thread so that
// constructing the widget never stalls the UI thread on a large deck folder.
auto *watcher = new QFutureWatcher<DeckScanResult>(this);
connect(watcher, &QFutureWatcher<DeckScanResult>::finished, this, [this, watcher, generation] {
watcher->deleteLater();
// The continuation is bound to this model as its context object: it runs on the UI thread,
// and Qt drops it if the model is destroyed first, so no result is delivered to a dead model.
QFuture<DeckScanResult> scan =
QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); });
scan.then(this, [this, generation](const DeckScanResult &result) {
if (generation != scanGeneration) {
return; // A newer scan started while this one was running; drop the stale result.
}
const DeckScanResult result = watcher->result();
folderPaths = result.folderPaths;
if (result.decks.isEmpty()) {
@ -268,9 +268,6 @@ void VisualDeckStorageModel::startScan()
beginLoad(row);
}
});
watcher->setFuture(
QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); }));
}
void VisualDeckStorageModel::beginLoad(int row)
@ -286,32 +283,7 @@ void VisualDeckStorageModel::beginLoad(int row)
const DeckFileFormat::Format fmt = DeckFileFormat::getFormatFromName(filePath);
const int generation = scanGeneration;
auto *watcher = new QFutureWatcher<std::optional<DeckLoadResult>>(this);
connect(watcher, &QFutureWatcher<std::optional<DeckLoadResult>>::finished, this,
[this, watcher, filePath, generation] {
watcher->deleteLater();
if (generation != scanGeneration) {
return; // The deck list was re-scanned while this load was running; drop the stale result.
}
// Queue the result and apply a bounded number per event loop turn so that
// finishing hundreds of loads at once cannot stall the UI thread.
const std::optional<DeckLoadResult> result = watcher->result();
PendingDeckLoad pending;
pending.filePath = filePath;
pending.generation = generation;
if (result) {
pending.ok = true;
pending.deck = std::move(result->deck);
pending.lastModified = result->lastModified;
pending.colorIdentity = std::move(result->colorIdentity);
}
pendingLoads.append(std::move(pending));
schedulePendingLoadDrain();
});
watcher->setFuture(QtConcurrent::run([filePath, fmt]() -> std::optional<DeckLoadResult> {
auto load = QtConcurrent::run([filePath, fmt]() -> std::optional<DeckLoadResult> {
std::optional<LoadedDeck> deck = DeckLoader::loadFromFile(filePath, fmt, false);
if (!deck) {
return std::nullopt;
@ -320,7 +292,28 @@ void VisualDeckStorageModel::beginLoad(int row)
// keep the completion handler on the UI thread cheap.
const QString colorIdentity = getDeckColorIdentity(deck->deckList, CardDatabaseManager::query());
return DeckLoadResult{std::move(*deck), QFileInfo(filePath).lastModified(), colorIdentity};
}));
});
// Bound to this model as context: runs on the UI thread and is dropped if the model is destroyed first.
load.then(this, [this, filePath, generation](std::optional<DeckLoadResult> result) {
if (generation != scanGeneration) {
return; // The deck list was re-scanned while this load was running; drop the stale result.
}
// Queue the result and apply a bounded number per event loop turn so that
// finishing hundreds of loads at once cannot stall the UI thread.
PendingDeckLoad pending;
pending.filePath = filePath;
pending.generation = generation;
if (result) {
pending.ok = true;
pending.deck = std::move(result->deck);
pending.lastModified = result->lastModified;
pending.colorIdentity = std::move(result->colorIdentity);
}
pendingLoads.append(std::move(pending));
schedulePendingLoadDrain();
});
}
void VisualDeckStorageModel::schedulePendingLoadDrain()