mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-11 20:43:25 -08:00
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:
commit
b64e168026
179 changed files with 4162 additions and 832 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -294,6 +294,7 @@ void ZoneViewWidget::retranslateUi()
|
|||
|
||||
shuffleCheckBox.setText(tr("shuffle when closing"));
|
||||
pileViewCheckBox.setText(tr("pile view"));
|
||||
zone->retranslateEdgeMarker();
|
||||
}
|
||||
|
||||
void ZoneViewWidget::stopWindowDrag()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
109
cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp
Normal file
109
cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp
Normal 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;
|
||||
}
|
||||
37
cockatrice/src/interface/widgets/tabs/alt_table_delegate.h
Normal file
37
cockatrice/src/interface/widgets/tabs/alt_table_delegate.h
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
699
cockatrice/src/interface/widgets/tabs/tab_alts.cpp
Normal file
699
cockatrice/src/interface/widgets/tabs/tab_alts.cpp
Normal 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())));
|
||||
}
|
||||
}
|
||||
102
cockatrice/src/interface/widgets/tabs/tab_alts.h
Normal file
102
cockatrice/src/interface/widgets/tabs/tab_alts.h
Normal 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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
27
cockatrice/src/interface/widgets/utility/epoch_format.h
Normal file
27
cockatrice/src/interface/widgets/utility/epoch_format.h
Normal 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
|
||||
41
cockatrice/src/interface/widgets/utility/pill_painter.cpp
Normal file
41
cockatrice/src/interface/widgets/utility/pill_painter.cpp
Normal 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;
|
||||
}
|
||||
29
cockatrice/src/interface/widgets/utility/pill_painter.h
Normal file
29
cockatrice/src/interface/widgets/utility/pill_painter.h
Normal 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
|
||||
|
|
@ -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())));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
32
cockatrice/src/interface/widgets/utility/table_activation.h
Normal file
32
cockatrice/src/interface/widgets/utility/table_activation.h
Normal 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
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue