Merge branch 'master' into tooomm-qt5

This commit is contained in:
tooomm 2026-08-27 06:33:34 +02:00
commit 3bc08ef94c
357 changed files with 23069 additions and 2916 deletions

View file

@ -41,6 +41,11 @@ void ColorIdentityWidget::populateManaSymbolWidgets()
// clear old layout
QtUtils::clearLayoutRec(layout);
// The freshly created symbols haven't been sized yet, so force the next resize pass
// to apply the symbol size again.
lastIconSize = -1;
lastWidth = -1;
// populate mana symbols
if (SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDrawUnusedColorIdentities()) {
for (const QString symbol : fullColorIdentity) {
@ -73,20 +78,33 @@ void ColorIdentityWidget::toggleUnusedVisibility()
void ColorIdentityWidget::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
QList<ManaSymbolWidget *> manaSymbols = findChildren<ManaSymbolWidget *>();
if (!manaSymbols.isEmpty()) {
int totalWidth = event->size().width();
int totalHeight = totalWidth / 6; // Set height to 1/4 of the width
setFixedHeight(totalHeight);
const int totalWidth = event->size().width();
if (totalWidth == lastWidth && lastIconSize != -1) {
return;
}
lastWidth = totalWidth;
int spacing = layout->spacing();
int count = manaSymbols.size();
int availableWidth = totalWidth - (spacing * (count - 1));
int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height
const int totalHeight = totalWidth / 6; // Set height to 1/4 of the width
setFixedHeight(totalHeight);
for (ManaSymbolWidget *manaSymbol : manaSymbols) {
manaSymbol->setFixedSize(iconSize, iconSize);
const int count = layout->count();
if (count == 0) {
return;
}
const int spacing = layout->spacing();
const int availableWidth = totalWidth - (spacing * (count - 1));
const int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height
if (iconSize == lastIconSize) {
return;
}
lastIconSize = iconSize;
for (int i = 0; i < count; ++i) {
if (auto *w = qobject_cast<ManaSymbolWidget *>(layout->itemAt(i)->widget())) {
w->setFixedSize(iconSize, iconSize);
}
}
}

View file

@ -30,6 +30,8 @@ public slots:
private:
QString colorIdentity;
QHBoxLayout *layout;
int lastIconSize = -1; ///< The symbol size last applied, to skip redundant resize passes.
int lastWidth = -1; ///< The width last processed, to skip redundant resize passes.
};
#endif // COLOR_IDENTITY_WIDGET_H

View file

@ -17,7 +17,7 @@ class ManaCostWidget : public QWidget
public:
explicit ManaCostWidget(QWidget *parent, CardInfoPtr card);
QStringList parseManaCost(const QString &manaString);
static QStringList parseManaCost(const QString &manaString);
public slots:
void resizeEvent(QResizeEvent *event) override;

View file

@ -1,15 +1,15 @@
#include "mana_symbol_widget.h"
#include "../../../../client/settings/cache_settings.h"
#include "../../../pixel_map_generator.h"
#include <QResizeEvent>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
ManaSymbolWidget::ManaSymbolWidget(QWidget *parent, QString _symbol, bool _isActive, bool _mayBeToggled)
: QLabel(parent), symbol(_symbol), isActive(_isActive), mayBeToggled(_mayBeToggled)
: QLabel(parent), symbol(std::move(_symbol)), isActive(_isActive), mayBeToggled(_mayBeToggled)
{
loadManaIcon();
setPixmap(manaIcon.scaled(50, 50, Qt::KeepAspectRatio, Qt::SmoothTransformation));
setPixmap(ManaSymbolPixmapGenerator::generatePixmap(symbol, QSize(50, 50)));
setMaximumWidth(50);
// Initialize opacity effect
@ -64,16 +64,13 @@ void ManaSymbolWidget::mousePressEvent(QMouseEvent *event)
void ManaSymbolWidget::resizeEvent(QResizeEvent *event)
{
QLabel::resizeEvent(event);
setPixmap(manaIcon.scaled(event->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
}
const QSize newSize = event->size();
void ManaSymbolWidget::loadManaIcon()
{
QString filename = "theme:icons/mana/";
if (symbol == "W" || symbol == "U" || symbol == "B" || symbol == "R" || symbol == "G") {
filename += symbol;
// Skip the rescale when the size didn't actually change: layout passes resize these
// widgets repeatedly with identical sizes.
if (newSize.isEmpty() || pixmap().size() == newSize) {
return;
}
manaIcon = QPixmap(filename);
setPixmap(ManaSymbolPixmapGenerator::generatePixmap(symbol, newSize));
}

View file

@ -33,8 +33,6 @@ public:
return symbol[0];
}
void loadManaIcon();
public slots:
void resizeEvent(QResizeEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
@ -44,7 +42,6 @@ signals:
private:
QString symbol;
QPixmap manaIcon;
bool isActive;
bool mayBeToggled;
QGraphicsOpacityEffect *opacityEffect;

View file

@ -0,0 +1,61 @@
#include "art_crop_attribution.h"
#include <QFontMetrics>
#include <QObject>
#include <QPainter>
#include <libcockatrice/card/printing/exact_card.h>
QString buildArtAttribution(const ExactCard &card)
{
const QString artist = card.getPrinting().getArtist();
if (artist.isEmpty()) {
return QString();
}
return QObject::tr("Art: %1").arg(artist);
}
QRectF paintArtAttribution(QPainter &painter,
const QRectF &rect,
const QString &attribution,
Qt::Alignment anchor,
qreal scale)
{
if (attribution.isEmpty()) {
return QRectF();
}
painter.save();
QFont font = painter.font();
font.setPointSizeF(qMax(6.0, font.pointSizeF() * scale));
painter.setFont(font);
const QFontMetrics fm(font);
const qreal maxTextWidth = rect.width() * 0.45;
const QString elided = fm.elidedText(attribution, Qt::ElideRight, qMax(qreal(80.0) * scale, maxTextWidth));
const qreal pad = 6.0 * scale;
QRectF captionRect(QPointF(0, 0), QSizeF(fm.horizontalAdvance(elided) + pad * 2.0, fm.height() + pad * 2.0));
const qreal margin = 4.0 * scale;
if (anchor.testFlag(Qt::AlignLeft)) {
captionRect.moveLeft(rect.left() + margin);
} else {
captionRect.moveRight(rect.right() - margin);
}
if (anchor.testFlag(Qt::AlignTop)) {
captionRect.moveTop(rect.top() + margin);
} else {
captionRect.moveBottom(rect.bottom() - margin);
}
painter.setPen(Qt::NoPen);
painter.setBrush(QColor(0, 0, 0, 120));
painter.drawRoundedRect(captionRect, 4, 4);
painter.setPen(QColor(255, 255, 255, 220));
painter.drawText(captionRect, Qt::AlignCenter, elided);
painter.restore();
return captionRect;
}

View file

@ -0,0 +1,41 @@
#ifndef COCKATRICE_ART_CROP_ATTRIBUTION_H
#define COCKATRICE_ART_CROP_ATTRIBUTION_H
#include <QStringView>
class ExactCard;
class QPainter;
class QRectF;
class QString;
/**
* @brief Builds an attribution caption for a cropped card art display.
*
* When a card image's art region is shown cropped (an "art crop"), the artist
* should be credited in the same interface. Returns an empty string when the
* database has no artist data for the card.
*
* @param card The card whose art is being displayed.
* @return Caption such as "Art: John Avon", or empty.
*/
QString buildArtAttribution(const ExactCard &card);
/**
* @brief Paints an attribution caption in a corner of a rect.
*
* Draws a subtle semi-transparent pill containing the caption, elided to fit.
*
* @param painter Painter to draw with.
* @param rect The area (e.g. the cropped art region) the caption belongs to.
* @param attribution Caption text (see buildArtAttribution()).
* @param anchor Corner of @p rect to pin the pill to (default bottom-right).
* @param scale Size multiplier for the pill (e.g. 0.8 for a smaller pill).
* @return The rect the pill was drawn in, or an empty rect if @p attribution is empty.
*/
QRectF paintArtAttribution(QPainter &painter,
const QRectF &rect,
const QString &attribution,
Qt::Alignment anchor = Qt::AlignRight | Qt::AlignBottom,
qreal scale = 1.0);
#endif // COCKATRICE_ART_CROP_ATTRIBUTION_H

View file

@ -52,12 +52,29 @@ void CardInfoPictureEnlargedWidget::loadPixmap(const QSize &size)
* @param size The desired size for the pixmap.
*
* Sets the widget's pixmap to the card image and resizes the widget to match the specified size. Triggers a repaint.
*
* When the image is not yet cached, the pixmap is cleared (instead of showing a stale previous card) and the widget
* refreshes automatically once the card image finishes loading.
*/
void CardInfoPictureEnlargedWidget::setCardPixmap(const ExactCard &_card, const QSize size)
{
if (card.getCardPtr()) {
disconnect(card.getCardPtr().data(), nullptr, this, nullptr);
}
card = _card;
// Clear any previous card's art so we never paint a stale pixmap while the new image loads
enlargedPixmap = QPixmap();
loadPixmap(size);
if (card.getCardPtr()) {
connect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, this, [this]() {
loadPixmap(this->size());
update();
});
}
setFixedSize(size); // Set the widget size to the enlarged size
update(); // Trigger a repaint

View file

@ -2,6 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_settings.h"
#include "../playmat/playmat_settings_dialog.h"
#include "../settings_page/user_interface_settings_page.h"
#include "../tabs/api/commander_spellbook/commander_bracket_widget.h"
#include "deck_list_style_proxy.h"
@ -11,10 +12,12 @@
#include <QDockWidget>
#include <QHeaderView>
#include <QLabel>
#include <QPushButton>
#include <QSplitter>
#include <QTextEdit>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/deck_editor_settings.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/macros.h>
#include <libcockatrice/utility/string_limits.h>
@ -228,10 +231,18 @@ void DeckEditorDeckDockWidget::createDeckDock()
upperLayout->addWidget(bannerCardLabel, 4, 0);
upperLayout->addWidget(bannerCardComboBox, 4, 1);
upperLayout->addWidget(deckTagsDisplayWidget, 5, 1);
playmatLabel = new QLabel();
playmatLabel->setObjectName("playmatLabel");
playmatLabel->setText(tr("Playmat"));
playmatSettingsButton = new QPushButton(tr("Edit Playmat..."));
connect(playmatSettingsButton, &QPushButton::clicked, this, &DeckEditorDeckDockWidget::openPlaymatSettings);
upperLayout->addWidget(playmatLabel, 5, 0);
upperLayout->addWidget(playmatSettingsButton, 5, 1);
upperLayout->addWidget(activeGroupCriteriaLabel, 6, 0);
upperLayout->addWidget(activeGroupCriteriaComboBox, 6, 1);
upperLayout->addWidget(deckTagsDisplayWidget, 6, 1);
upperLayout->addWidget(activeGroupCriteriaLabel, 7, 0);
upperLayout->addWidget(activeGroupCriteriaComboBox, 7, 1);
hashLabel1 = new QLabel();
hashLabel1->setObjectName("hashLabel1");
@ -440,6 +451,35 @@ void DeckEditorDeckDockWidget::writeBannerCard(int index)
deckStateManager->setBannerCard(bannerCard);
}
void DeckEditorDeckDockWidget::openPlaymatSettings()
{
PlaymatInfo current = deckStateManager->getMetadata().playmat;
PlaymatSettingsDialog dialog(current.card, current.params, this);
if (dialog.exec() == QDialog::Accepted) {
CardRef newCard = dialog.card();
PlaymatParams newParams = dialog.params();
if (newCard.isEmpty()) {
deckStateManager->setPlaymat(PlaymatInfo{});
} else {
deckStateManager->setPlaymat({newCard, newParams});
}
updatePlaymatLabel();
}
}
void DeckEditorDeckDockWidget::updatePlaymatLabel()
{
CardRef playmat = deckStateManager->getMetadata().playmat.card;
if (playmat.isEmpty()) {
playmatSettingsButton->setText(tr("Edit Playmat..."));
} else {
playmatSettingsButton->setText(tr("Edit Playmat (%1)").arg(playmat.name));
}
}
void DeckEditorDeckDockWidget::applyActiveGroupCriteria()
{
getModel()->setActiveGroupCriteria(
@ -497,6 +537,7 @@ void DeckEditorDeckDockWidget::syncDisplayWidgetsToModel()
syncBannerCardComboBoxSelectionWithDeck();
updateBannerCardComboBox();
bannerCardComboBox->blockSignals(false);
updatePlaymatLabel();
updateHash();
formatComboBox->blockSignals(true);

View file

@ -15,11 +15,15 @@
#include "deck_list_history_manager_widget.h"
#include "deck_list_style_proxy.h"
#include <QCheckBox>
#include <QComboBox>
#include <QDockWidget>
#include <QLabel>
#include <QPushButton>
#include <QTextEdit>
#include <QTreeView>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/deck_list/deck_list.h>
class CommanderBracketWidget;
class DeckListModel;
@ -33,6 +37,8 @@ public:
DeckListStyleProxy *proxy;
QTreeView *deckView;
QComboBox *bannerCardComboBox;
QLabel *playmatLabel;
QPushButton *playmatSettingsButton;
void createDeckDock();
ExactCard getCurrentCard();
void retranslateUi();
@ -102,6 +108,8 @@ private slots:
void writeName();
void writeComments();
void writeBannerCard(int);
void openPlaymatSettings();
void updatePlaymatLabel();
void applyActiveGroupCriteria();
void setSelectedIndex(const QModelIndex &newCardIndex, bool preserveWidgetFocus);
void updateHash();

View file

@ -142,6 +142,19 @@ void DeckStateManager::setBannerCard(const CardRef &bannerCard)
doMetadataModified();
}
void DeckStateManager::setPlaymat(const PlaymatInfo &playmat)
{
PlaymatInfo previous = deckList->getPlaymat();
if (previous == playmat) {
return;
}
requestHistorySave(tr("Set playmat to %1").arg(playmat.card.name));
deckList->setPlaymat(playmat);
doMetadataModified();
}
void DeckStateManager::setTags(const QStringList &tags)
{
QStringList previous = deckList->getTags();

View file

@ -171,6 +171,7 @@ public:
void setName(const QString &name);
void setComments(const QString &comments);
void setBannerCard(const CardRef &bannerCard);
void setPlaymat(const PlaymatInfo &playmat);
void setTags(const QStringList &tags);
void setFormat(const QString &format);
///@}

View file

@ -214,6 +214,7 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QS
spectatorsNeedPasswordCheckBox->setChecked(gameInfo.spectators_need_password());
spectatorsCanTalkCheckBox->setChecked(gameInfo.spectators_can_chat());
spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient());
shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load());
QSet<int> types;
for (int i = 0; i < gameInfo.game_types_size(); ++i) {

View file

@ -30,7 +30,7 @@ DlgFilterGames::DlgFilterGames(const QMap<int, QString> &_allGameTypes,
hideFullGames = new QCheckBox(tr("Hide full games"));
hideFullGames->setChecked(filters.hideFullGames);
hideGamesThatStarted = new QCheckBox(tr("Hide games that have started"));
hideGamesThatStarted = new QCheckBox(tr("Hide started games"));
hideGamesThatStarted->setChecked(filters.hideGamesThatStarted);
hidePasswordProtectedGames = new QCheckBox(tr("Hide password protected games"));
@ -57,16 +57,16 @@ DlgFilterGames::DlgFilterGames(const QMap<int, QString> &_allGameTypes,
gameNameFilterEdit->setText(filters.gameNameFilter);
auto *gameNameFilterLabel = new QLabel(tr("Game &description:"));
gameNameFilterLabel->setBuddy(gameNameFilterEdit);
creatorNameFilterEdit = new QLineEdit;
creatorNameFilterEdit->setText(filters.creatorNameFilters.join(", "));
auto *creatorNameFilterLabel = new QLabel(tr("&Creator name:"));
creatorNameFilterLabel->setBuddy(creatorNameFilterEdit);
hostNameFilterEdit = new QLineEdit;
hostNameFilterEdit->setText(filters.hostNameFilters.join(", "));
auto *hostNameFilterLabel = new QLabel(tr("&Host name:"));
hostNameFilterLabel->setBuddy(hostNameFilterEdit);
auto *generalGrid = new QGridLayout;
generalGrid->addWidget(gameNameFilterLabel, 0, 0);
generalGrid->addWidget(gameNameFilterEdit, 0, 1);
generalGrid->addWidget(creatorNameFilterLabel, 1, 0);
generalGrid->addWidget(creatorNameFilterEdit, 1, 1);
generalGrid->addWidget(hostNameFilterLabel, 1, 0);
generalGrid->addWidget(hostNameFilterEdit, 1, 1);
generalGrid->addWidget(maxGameAgeLabel, 2, 0);
generalGrid->addWidget(maxGameAgeComboBox, 2, 1);
generalGroupBox = new QGroupBox(tr("General"));
@ -193,7 +193,7 @@ GameFilterConfigs DlgFilterGames::getFilters() const
hideNotBuddyCreatedGames->isChecked(),
hideOpenDecklistGames->isChecked(),
gameNameFilterEdit->text(),
getCreatorNameFilters(),
getHostNameFilters(),
getGameTypeFilter(),
maxPlayersFilterMinSpinBox->value(),
maxPlayersFilterMaxSpinBox->value(),
@ -216,9 +216,9 @@ void DlgFilterGames::toggleSpectatorCheckboxEnabledness(bool spectatorsEnabled)
showOnlyIfSpectatorsCanSeeHands->setDisabled(!spectatorsEnabled);
}
QStringList DlgFilterGames::getCreatorNameFilters() const
QStringList DlgFilterGames::getHostNameFilters() const
{
return creatorNameFilterEdit->text().split(",", Qt::SkipEmptyParts);
return hostNameFilterEdit->text().split(",", Qt::SkipEmptyParts);
}
QSet<int> DlgFilterGames::getGameTypeFilter() const

View file

@ -35,7 +35,7 @@ private:
QCheckBox *hideNotBuddyCreatedGames;
QCheckBox *hideOpenDecklistGames;
QLineEdit *gameNameFilterEdit;
QLineEdit *creatorNameFilterEdit;
QLineEdit *hostNameFilterEdit;
QMap<int, QCheckBox *> gameTypeFilterCheckBoxes;
QSpinBox *maxPlayersFilterMinSpinBox;
QSpinBox *maxPlayersFilterMaxSpinBox;
@ -50,7 +50,7 @@ private:
const GamesProxyModel *gamesProxyModel;
const QMap<QTime, QString> gameAgeMap;
[[nodiscard]] QStringList getCreatorNameFilters() const;
[[nodiscard]] QStringList getHostNameFilters() const;
[[nodiscard]] QSet<int> getGameTypeFilter() const;
[[nodiscard]] QTime getMaxGameAge() const;
[[nodiscard]] bool getShowSpectatorPasswordProtected() const;

View file

@ -0,0 +1,112 @@
#include "dlg_invite_to_game.h"
#include "../server/user/user_list_manager.h"
#include "../server/user/user_list_widget.h"
#include "../tabs/tab_supervisor.h"
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QScreen>
#include <QUrl>
#include <QUrlQuery>
#include <QVBoxLayout>
DlgInviteToGame::DlgInviteToGame(TabSupervisor *_tabSupervisor,
const QString &_inviteUrl,
bool _onlyBuddies,
const QStringList &_excludeUserNames,
QWidget *parent)
: QDialog(parent), tabSupervisor(_tabSupervisor), inviteUrl(_inviteUrl), onlyBuddies(_onlyBuddies),
excludeUserNames(_excludeUserNames)
{
setModal(true);
searchEdit = new QLineEdit(this);
searchEdit->setClearButtonEnabled(true);
connect(searchEdit, &QLineEdit::textChanged, this, &DlgInviteToGame::searchTextChanged);
// The embedded list is the real room user list without the hover popup:
// same manager, same delegate/painter, same sections, live via manager
// signals while the modal loop runs.
UserListManager *manager = tabSupervisor->getUserListManager();
userList = new UserListWidget(tabSupervisor, tabSupervisor->getClient(), UserListWidget::RoomList, this,
/*hasUserInfoPopup=*/false);
userList->setUserFilter([this, manager](const QString &name, bool online) {
return !excludeUserNames.contains(name) && online && !manager->isUserIgnored(name);
});
if (onlyBuddies) {
userList->setSectioned({UserListWidget::Section::Buddy});
} else {
userList->setSectioned({UserListWidget::Section::Buddy, UserListWidget::Section::Online});
}
userList->bind(manager);
userList->rebuild();
connect(userList, &UserListWidget::userActivated, this, &DlgInviteToGame::inviteCurrentUser);
connect(userList, &UserListWidget::currentUserChanged, this, [this](const QString &userName) {
currentUserName = userName;
inviteButton->setEnabled(!userName.isEmpty());
});
inviteButton = new QPushButton(this);
inviteButton->setEnabled(false);
inviteButton->setDefault(true);
connect(inviteButton, &QPushButton::clicked, this, [this] { inviteCurrentUser(currentUserName); });
cancelButton = new QPushButton(this);
connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject);
auto *buttonRow = new QHBoxLayout;
buttonRow->addStretch();
buttonRow->addWidget(inviteButton);
buttonRow->addWidget(cancelButton);
auto *layout = new QVBoxLayout(this);
layout->addWidget(searchEdit);
layout->addWidget(userList, 1);
layout->addLayout(buttonRow);
retranslateUi();
// Default to a comfortably tall dialog so the list has room to breathe,
// capped by the available screen. No minimum is enforced: small screens
// and manual resizing can go shorter than this.
const QRect availableScreen = QGuiApplication::primaryScreen()->availableGeometry();
resize(sizeHint().width(), qMin(sizeHint().height() * 3, availableScreen.height() * 4 / 5));
}
void DlgInviteToGame::searchTextChanged(const QString &text)
{
userList->setFilterText(text);
}
void DlgInviteToGame::inviteCurrentUser(const QString &userName)
{
if (userName.isEmpty()) {
return;
}
// The invite link carries the game's id and, when the game has one, its
// description (makeGameJoinLink embeds both). Read them back so the prefix
// names the game by description first, then its id — identical to the
// context-menu invite so recipients see one consistent message style.
const QUrl inviteUrlObj(inviteUrl);
const QUrlQuery inviteQuery(inviteUrlObj);
const int gameId = inviteQuery.queryItemValue("gameid").toInt();
const QString gameDescription = inviteQuery.queryItemValue("game");
const QString prefix = gameDescription.isEmpty()
? tr("Join my game (#%1):").arg(gameId)
: tr("Join my game \"%1\" (#%2):").arg(gameDescription).arg(gameId);
tabSupervisor->sendInviteToUser(userName, prefix + " " + inviteUrl);
accept();
}
void DlgInviteToGame::retranslateUi()
{
setWindowTitle(tr("Invite to Game"));
searchEdit->setPlaceholderText(tr("Search users..."));
inviteButton->setText(tr("Invite"));
cancelButton->setText(tr("Cancel"));
}

View file

@ -0,0 +1,46 @@
/**
* @file dlg_invite_to_game.h
* @ingroup RoomDialogs
*/
//! \todo Document this file.
#ifndef DLG_INVITE_TO_GAME_H
#define DLG_INVITE_TO_GAME_H
#include <QDialog>
#include <QStringList>
class QLineEdit;
class QPushButton;
class TabSupervisor;
class UserListWidget;
class DlgInviteToGame : public QDialog
{
Q_OBJECT
public:
DlgInviteToGame(TabSupervisor *_tabSupervisor,
const QString &_inviteUrl,
bool _onlyBuddies,
const QStringList &_excludeUserNames,
QWidget *parent = nullptr);
private slots:
void searchTextChanged(const QString &text);
void inviteCurrentUser(const QString &userName);
private:
TabSupervisor *tabSupervisor;
QString inviteUrl;
bool onlyBuddies;
QStringList excludeUserNames;
QString currentUserName;
QLineEdit *searchEdit;
UserListWidget *userList;
QPushButton *inviteButton;
QPushButton *cancelButton;
void retranslateUi();
};
#endif

View file

@ -0,0 +1,297 @@
#include "dlg_my_reports.h"
#include "../utility/report_utils.h"
#include "abstract_client.h"
#include <QFontDatabase>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QSplitter>
#include <QTableWidget>
#include <QTextEdit>
#include <QVBoxLayout>
#include <libcockatrice/protocol/pb/command_report_add_comment.pb.h>
#include <libcockatrice/protocol/pb/command_report_details.pb.h>
#include <libcockatrice/protocol/pb/command_report_my_list.pb.h>
#include <libcockatrice/protocol/pb/response_report_details.pb.h>
#include <libcockatrice/protocol/pb/response_report_my_list.pb.h>
#include <libcockatrice/protocol/pending_command.h>
namespace
{
constexpr int COL_ID = 0;
constexpr int COL_TIME = 1;
constexpr int COL_REPORTED = 2;
constexpr int COL_CATEGORY = 3;
constexpr int COL_GAMEID = 4;
constexpr int COL_STATUS = 5;
constexpr int COL_ASSIGNED = 6;
constexpr int COL_COUNT = 7;
} // namespace
DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent)
: QDialog(parent), client(_client), selectedReportId(-1)
{
setWindowTitle(tr("My Reports"));
setMinimumSize(800, 500);
table = new QTableWidget(0, COL_COUNT);
table->setHorizontalHeaderLabels(
{tr("#"), tr("Time"), tr("Reported User"), tr("Category"), tr("Game ID"), tr("Status"), tr("Assigned To")});
table->setSelectionBehavior(QAbstractItemView::SelectRows);
table->setSelectionMode(QAbstractItemView::SingleSelection);
table->setEditTriggers(QAbstractItemView::NoEditTriggers);
table->setSortingEnabled(true);
table->verticalHeader()->setVisible(false);
table->setAlternatingRowColors(true);
table->horizontalHeader()->setSectionResizeMode(COL_TIME, QHeaderView::ResizeToContents);
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
connect(table, &QTableWidget::itemSelectionChanged, this, &DlgMyReports::onSelectionChanged);
auto *detailsGroup = new QGroupBox(tr("Report Details"));
descriptionEdit = new QTextEdit;
descriptionEdit->setReadOnly(true);
descriptionEdit->setFixedHeight(80);
auto *chatGroup = new QGroupBox(tr("Chat Log Context"));
chatLogEdit = new QTextEdit;
chatLogEdit->setReadOnly(true);
QFont monoFont("monospace");
monoFont.setStyleHint(QFont::Monospace);
const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize();
if (systemPointSize > 0) {
monoFont.setPointSize(systemPointSize);
}
chatLogEdit->setFont(monoFont);
auto *chatLayout = new QVBoxLayout(chatGroup);
chatLayout->setContentsMargins(4, 4, 4, 4);
chatLayout->addWidget(chatLogEdit);
auto *commentsLabel = new QLabel(tr("Comments:"));
commentsEdit = new QTextEdit;
commentsEdit->setReadOnly(true);
commentsEdit->setFixedHeight(120);
auto *addCommentLabel = new QLabel(tr("Add a comment:"));
commentInput = new QLineEdit;
commentInput->setPlaceholderText(tr("Type your comment here..."));
commentButton = new QPushButton(tr("Send"));
commentButton->setEnabled(false);
connect(commentButton, &QPushButton::clicked, this, &DlgMyReports::addComment);
connect(commentInput, &QLineEdit::returnPressed, this, &DlgMyReports::addComment);
auto *detailsLayout = new QVBoxLayout(detailsGroup);
detailsLayout->setContentsMargins(4, 4, 4, 4);
detailsLayout->addWidget(descriptionEdit);
detailsLayout->addWidget(chatGroup);
detailsLayout->addWidget(commentsLabel);
detailsLayout->addWidget(commentsEdit);
detailsLayout->addWidget(addCommentLabel);
auto *commentRow = new QHBoxLayout;
commentRow->addWidget(commentInput);
commentRow->addWidget(commentButton);
detailsLayout->addLayout(commentRow);
closeButton = new QPushButton(tr("Close"));
connect(closeButton, &QPushButton::clicked, this, &QDialog::accept);
refreshButton = new QPushButton(tr("Refresh"));
connect(refreshButton, &QPushButton::clicked, this, &DlgMyReports::refreshList);
statusLabel = new QLabel;
auto *bottomBar = new QHBoxLayout;
bottomBar->addWidget(statusLabel);
bottomBar->addStretch();
bottomBar->addWidget(refreshButton);
bottomBar->addWidget(closeButton);
auto *layout = new QVBoxLayout(this);
layout->addWidget(table, 1);
layout->addWidget(detailsGroup);
layout->addLayout(bottomBar);
setActionsEnabled(false);
refreshList();
}
void DlgMyReports::refreshList()
{
selectedReportIdBeforeRefresh = selectedReportId;
commentDraftBeforeRefresh = commentInput->text();
statusLabel->setText(tr("Loading..."));
refreshButton->setEnabled(false);
table->setRowCount(0);
currentReports.clear();
descriptionEdit->clear();
chatLogEdit->clear();
commentsEdit->clear();
commentInput->clear();
setActionsEnabled(false);
selectedReportId = -1;
Command_ReportMyList cmd;
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportListResponse);
client->sendCommand(pend);
}
void DlgMyReports::reportListResponse(const Response &response)
{
refreshButton->setEnabled(true);
if (response.response_code() != Response::RespOk) {
statusLabel->setText(tr("Failed to load reports."));
return;
}
const Response_ReportMyList &resp = response.GetExtension(Response_ReportMyList::ext);
currentReports.clear();
for (int i = 0; i < resp.reports_size(); ++i) {
currentReports.append(resp.reports(i));
}
table->setSortingEnabled(false);
table->setRowCount(currentReports.size());
for (int row = 0; row < currentReports.size(); ++row) {
const ServerInfo_Report &r = currentReports[row];
report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID,
COL_STATUS, COL_ASSIGNED);
}
table->setSortingEnabled(true);
table->resizeColumnsToContents();
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
if (selectedReportIdBeforeRefresh >= 0) {
for (int row = 0; row < table->rowCount(); ++row) {
if (table->item(row, COL_ID) &&
table->item(row, COL_ID)->data(Qt::UserRole).toInt() == selectedReportIdBeforeRefresh) {
table->setCurrentCell(row, 0);
break;
}
}
}
if (commentInput->text().isEmpty()) {
commentInput->setText(commentDraftBeforeRefresh);
}
statusLabel->setText(tr("%1 report(s)").arg(currentReports.size()));
}
void DlgMyReports::onSelectionChanged()
{
const int row = table->currentRow();
if (row < 0 || !table->item(row, COL_ID)) {
descriptionEdit->clear();
chatLogEdit->clear();
commentsEdit->clear();
commentInput->clear();
commentButton->setEnabled(false);
selectedReportId = -1;
return;
}
const int reportId = table->item(row, COL_ID)->data(Qt::UserRole).toInt();
selectedReportId = reportId;
for (const ServerInfo_Report &r : currentReports) {
if (r.report_id() == reportId) {
descriptionEdit->setPlainText(QString::fromStdString(r.description()));
break;
}
}
chatLogEdit->setPlainText(tr("Loading..."));
commentsEdit->setPlainText(tr("Loading..."));
Command_ReportDetails cmd;
cmd.set_report_id(reportId);
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse);
client->sendCommand(pend);
QString status = table->item(row, COL_STATUS)->text();
bool canComment = (status == "open" || status == "assigned");
commentButton->setEnabled(canComment);
commentInput->setEnabled(canComment);
if (!canComment) {
commentInput->setPlaceholderText(tr("This report is closed."));
} else {
commentInput->setPlaceholderText(tr("Type your comment here..."));
}
}
void DlgMyReports::reportDetailsResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
if (selectedReportId == -1) {
return;
}
chatLogEdit->clear();
commentsEdit->setPlainText(tr("Failed to load report details."));
return;
}
const Response_ReportDetails &resp = response.GetExtension(Response_ReportDetails::ext);
const ServerInfo_Report &r = resp.report();
if (selectedReportId != r.report_id()) {
return;
}
loadReportDetails(r);
}
void DlgMyReports::loadReportDetails(const ServerInfo_Report &report)
{
report_utils::renderReportDetails(chatLogEdit, commentsEdit, report, tr("No comments yet."), tr("[Moderator]"),
tr("[You]"));
}
void DlgMyReports::addComment()
{
if (selectedReportId < 0) {
return;
}
QString text = commentInput->text().trimmed();
if (text.isEmpty()) {
return;
}
commentButton->setEnabled(false);
Command_ReportAddComment cmd;
cmd.set_report_id(selectedReportId);
cmd.set_comment(text.toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DlgMyReports::addCommentResponse);
client->sendCommand(pend);
}
void DlgMyReports::addCommentResponse(const Response &response)
{
if (response.response_code() == Response::RespOk) {
commentInput->clear();
refreshList();
} else {
commentButton->setEnabled(true);
}
}
void DlgMyReports::setActionsEnabled(bool enabled)
{
commentButton->setEnabled(enabled);
commentInput->setEnabled(enabled);
}

View file

@ -0,0 +1,52 @@
#ifndef COCKATRICE_DLG_MY_REPORTS_H
#define COCKATRICE_DLG_MY_REPORTS_H
#include <QDialog>
#include <QList>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_report.pb.h>
class AbstractClient;
class QTableWidget;
class QTextEdit;
class QLineEdit;
class QPushButton;
class QLabel;
class DlgMyReports : public QDialog
{
Q_OBJECT
public:
explicit DlgMyReports(AbstractClient *_client, QWidget *parent = nullptr);
private slots:
void refreshList();
void reportListResponse(const Response &response);
void onSelectionChanged();
void reportDetailsResponse(const Response &response);
void addComment();
void addCommentResponse(const Response &response);
private:
void loadReportDetails(const ServerInfo_Report &report);
void setActionsEnabled(bool enabled);
AbstractClient *client;
QTableWidget *table;
QTextEdit *descriptionEdit;
QTextEdit *chatLogEdit;
QTextEdit *commentsEdit;
QLineEdit *commentInput;
QPushButton *commentButton;
QPushButton *refreshButton;
QPushButton *closeButton;
QLabel *statusLabel;
QList<ServerInfo_Report> currentReports;
int selectedReportId;
int selectedReportIdBeforeRefresh = -1;
QString commentDraftBeforeRefresh;
};
#endif // COCKATRICE_DLG_MY_REPORTS_H

View file

@ -1,18 +1,62 @@
#include "dlg_register.h"
#include "../../../client/settings/cache_settings.h"
#include "../server/handle_public_servers.h"
#include "../server/user/user_info_connection.h"
#include <QCheckBox>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QMessageBox>
#include <QPushButton>
#include <QRadioButton>
#include <QVBoxLayout>
#include <libcockatrice/settings/servers_settings.h>
#include <libcockatrice/utility/string_limits.h>
DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent)
{
// ── Server picker ──────────────────────────────────────────────────
previousHostButton = new QRadioButton(tr("Known Hosts"), this);
previousHosts = new QComboBox(this);
btnDeleteServer = new QPushButton(this);
btnDeleteServer->setIcon(QPixmap("theme:icons/remove_row"));
btnDeleteServer->setToolTip(tr("Delete the currently selected saved server"));
btnDeleteServer->setFixedWidth(30);
connect(btnDeleteServer, &QPushButton::clicked, this, &DlgRegister::actRemoveSavedServer);
hps = new HandlePublicServers(this);
btnRefreshServers = new QPushButton(this);
btnRefreshServers->setIcon(QPixmap("theme:icons/sync"));
btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers"));
btnRefreshServers->setFixedWidth(30);
connect(hps, &HandlePublicServers::sigPublicServersDownloadedSuccessfully, this, [this] { rebuildComboBoxList(); });
connect(hps, &HandlePublicServers::sigPublicServersDownloadedUnsuccessfully, this,
&DlgRegister::rebuildComboBoxList);
connect(btnRefreshServers, &QPushButton::released, this, &DlgRegister::downloadThePublicServers);
newHostButton = new QRadioButton(tr("New Host"), this);
auto *serverPickerRow = new QHBoxLayout;
serverPickerRow->addWidget(previousHosts);
serverPickerRow->addWidget(btnDeleteServer);
serverPickerRow->addWidget(btnRefreshServers);
auto *serverGroupLayout = new QVBoxLayout;
serverGroupLayout->addWidget(previousHostButton);
serverGroupLayout->addLayout(serverPickerRow);
serverGroupLayout->addWidget(newHostButton);
auto *serverGroupBox = new QGroupBox(tr("Server"));
serverGroupBox->setLayout(serverGroupLayout);
// ── Registration fields ────────────────────────────────────────────
ServersSettings &servers = SettingsCache::instance().servers();
infoLabel = new QLabel(tr("Enter your information and the information of the server you'd like to register to.\n"
"Your email will be used to verify your account."));
@ -321,26 +365,28 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent)
realnameEdit->setMaxLength(MAX_NAME_LENGTH);
realnameLabel->setBuddy(realnameEdit);
// ── Layout ─────────────────────────────────────────────────────────
auto *grid = new QGridLayout;
grid->addWidget(infoLabel, 0, 0, 1, 2);
grid->addWidget(hostLabel, 1, 0);
grid->addWidget(hostEdit, 1, 1);
grid->addWidget(portLabel, 2, 0);
grid->addWidget(portEdit, 2, 1);
grid->addWidget(playernameLabel, 3, 0);
grid->addWidget(playernameEdit, 3, 1);
grid->addWidget(passwordLabel, 4, 0);
grid->addWidget(passwordEdit, 4, 1);
grid->addWidget(passwordConfirmationLabel, 5, 0);
grid->addWidget(passwordConfirmationEdit, 5, 1);
grid->addWidget(emailLabel, 6, 0);
grid->addWidget(emailEdit, 6, 1);
grid->addWidget(emailConfirmationLabel, 7, 0);
grid->addWidget(emailConfirmationEdit, 7, 1);
grid->addWidget(countryLabel, 9, 0);
grid->addWidget(countryEdit, 9, 1);
grid->addWidget(realnameLabel, 10, 0);
grid->addWidget(realnameEdit, 10, 1);
grid->addWidget(serverGroupBox, 0, 0, 1, 2);
grid->addWidget(infoLabel, 1, 0, 1, 2);
grid->addWidget(hostLabel, 2, 0);
grid->addWidget(hostEdit, 2, 1);
grid->addWidget(portLabel, 3, 0);
grid->addWidget(portEdit, 3, 1);
grid->addWidget(playernameLabel, 4, 0);
grid->addWidget(playernameEdit, 4, 1);
grid->addWidget(passwordLabel, 5, 0);
grid->addWidget(passwordEdit, 5, 1);
grid->addWidget(passwordConfirmationLabel, 6, 0);
grid->addWidget(passwordConfirmationEdit, 6, 1);
grid->addWidget(emailLabel, 7, 0);
grid->addWidget(emailEdit, 7, 1);
grid->addWidget(emailConfirmationLabel, 8, 0);
grid->addWidget(emailConfirmationEdit, 8, 1);
grid->addWidget(countryLabel, 10, 0);
grid->addWidget(countryEdit, 10, 1);
grid->addWidget(realnameLabel, 11, 0);
grid->addWidget(realnameEdit, 11, 1);
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgRegister::actOk);
@ -352,13 +398,115 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent)
setLayout(mainLayout);
setWindowTitle(tr("Register to server"));
setFixedHeight(sizeHint().height());
setMinimumWidth(300);
setMinimumWidth(360);
connect(previousHostButton, &QRadioButton::toggled, this, &DlgRegister::previousHostSelected);
connect(newHostButton, &QRadioButton::toggled, this, &DlgRegister::newHostSelected);
connect(previousHosts, &QComboBox::currentTextChanged, this, &DlgRegister::updateDisplayInfo);
previousHostButton->setChecked(true);
preRebuildComboBoxList();
}
DlgRegister::~DlgRegister() = default;
void DlgRegister::downloadThePublicServers()
{
btnRefreshServers->setDisabled(true);
previousHosts->clear();
previousHosts->addItem(placeHolderText);
hps->downloadPublicServers();
}
void DlgRegister::preRebuildComboBoxList()
{
UserConnection_Information uci;
savedHostList = uci.getServerInfo();
if (savedHostList.size() == 1) {
downloadThePublicServers();
} else {
rebuildComboBoxList();
}
}
void DlgRegister::rebuildComboBoxList(int failure)
{
Q_UNUSED(failure);
previousHosts->clear();
UserConnection_Information uci;
savedHostList = uci.getServerInfo();
auto &servers = SettingsCache::instance().servers();
QString previousHostName = servers.getPrevioushostName();
for (const auto &pair : savedHostList) {
const auto &tmp = pair.second;
QString saveName = tmp.getSaveName();
if (saveName.size()) {
previousHosts->addItem(saveName);
if (saveName.compare(previousHostName) == 0) {
previousHosts->setCurrentIndex(previousHosts->count() - 1);
}
}
}
btnRefreshServers->setDisabled(false);
}
void DlgRegister::previousHostSelected(bool state)
{
if (state) {
previousHosts->setDisabled(false);
btnRefreshServers->setDisabled(false);
hostEdit->setDisabled(true);
portEdit->setDisabled(true);
}
}
void DlgRegister::newHostSelected(bool state)
{
if (state) {
previousHosts->setDisabled(true);
btnRefreshServers->setDisabled(true);
hostEdit->setDisabled(false);
hostEdit->clear();
hostEdit->setPlaceholderText(tr("Server URL"));
portEdit->setDisabled(false);
portEdit->clear();
portEdit->setPlaceholderText(tr("Communication Port"));
playernameEdit->setDisabled(false);
playernameEdit->clear();
} else {
// Rebuild the list so the previously selected host's details are
// repopulated (mirrors DlgConnect::newHostSelected).
preRebuildComboBoxList();
}
}
void DlgRegister::updateDisplayInfo(const QString &saveName)
{
if (saveName.isEmpty() || saveName == placeHolderText) {
return;
}
UserConnection_Information uci;
QStringList _data = uci.getServerInfo(saveName);
if (_data.size() < 7) {
return;
}
hostEdit->setText(_data.at(1));
portEdit->setText(_data.at(2));
playernameEdit->setText(_data.at(3));
}
void DlgRegister::actOk()
{
//! \todo This stuff should be using QValidators.
if (passwordEdit->text().length() < 8) {
QMessageBox::critical(this, tr("Registration Warning"), tr("Your password is too short."));
return;
@ -375,5 +523,29 @@ void DlgRegister::actOk()
return;
}
ServersSettings &servers = SettingsCache::instance().servers();
if (newHostButton->isChecked()) {
// Persist the new host so it shows up in the Connect dialog later.
// The password is never stored: the account is not verified yet.
const QString host = hostEdit->text().trimmed();
if (!host.isEmpty()) {
servers.addNewServer(host, host, portEdit->text().trimmed(), playernameEdit->text().trimmed(), QString(),
false);
servers.setPrevioushostName(host);
}
} else {
const QString saveName = previousHosts->currentText();
if (!saveName.isEmpty() && saveName != placeHolderText) {
servers.setPrevioushostName(saveName);
}
}
accept();
}
void DlgRegister::actRemoveSavedServer()
{
SettingsCache::instance().servers().removeServer(hostEdit->text());
previousHosts->removeItem(previousHosts->currentIndex());
}

View file

@ -1,25 +1,24 @@
/**
* @file dlg_register.h
* @ingroup AccountDialogs
*/
//! \todo Document this file.
#ifndef DLG_REGISTER_H
#define DLG_REGISTER_H
#include <QComboBox>
#include <QDialog>
#include <QLineEdit>
#include <QMap>
class HandlePublicServers;
class QLabel;
class QPushButton;
class QCheckBox;
class QRadioButton;
class UserConnection_Information;
class DlgRegister : public QDialog
{
Q_OBJECT
public:
explicit DlgRegister(QWidget *parent = nullptr);
~DlgRegister() override;
[[nodiscard]] QString getHost() const
{
return hostEdit->text();
@ -48,15 +47,35 @@ public:
{
return realnameEdit->text();
}
public slots:
void downloadThePublicServers();
private slots:
void actOk();
void previousHostSelected(bool state);
void newHostSelected(bool state);
void updateDisplayInfo(const QString &saveName);
void preRebuildComboBoxList();
void rebuildComboBoxList(int failure = -1);
void actRemoveSavedServer();
private:
QRadioButton *newHostButton;
QRadioButton *previousHostButton;
QComboBox *previousHosts;
QPushButton *btnDeleteServer;
QPushButton *btnRefreshServers;
HandlePublicServers *hps;
QLabel *infoLabel, *hostLabel, *portLabel, *playernameLabel, *passwordLabel, *passwordConfirmationLabel,
*emailLabel, *emailConfirmationLabel, *countryLabel, *realnameLabel;
QLineEdit *hostEdit, *portEdit, *playernameEdit, *passwordEdit, *passwordConfirmationEdit, *emailEdit,
*emailConfirmationEdit, *realnameEdit;
QComboBox *countryEdit;
QMap<QString, std::pair<QString, UserConnection_Information>> savedHostList;
const QString placeHolderText = tr("Downloading...");
};
#endif
#endif // DLG_REGISTER_H

View file

@ -0,0 +1,191 @@
#include "dlg_report_user.h"
#include "abstract_client.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFontDatabase>
#include <QGridLayout>
#include <QGroupBox>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QTextEdit>
#include <QVBoxLayout>
#include <libcockatrice/protocol/pb/command_report.pb.h>
#include <libcockatrice/protocol/pending_command.h>
DlgReportUser::DlgReportUser(AbstractClient *_client,
const QString &_reportedUser,
int _gameId,
const QString &_autoChatLog,
QWidget *parent)
: QDialog(parent), client(_client), reportedUser(_reportedUser), gameId(_gameId)
{
setWindowTitle(tr("Report User"));
setMinimumWidth(500);
auto *infoLabel =
new QLabel(tr("Reports are reviewed by moderators. False reports may result in account penalties."));
infoLabel->setWordWrap(true);
infoLabel->setStyleSheet("color: palette(placeholderText); padding: 5px;");
auto *reportGroup = new QGroupBox(tr("Report Details"));
auto *reportGrid = new QGridLayout(reportGroup);
reportGrid->addWidget(new QLabel(tr("Reported User:")), 0, 0);
reportedUserLabel = new QLabel(reportedUser);
reportedUserLabel->setStyleSheet("font-weight: bold;");
reportGrid->addWidget(reportedUserLabel, 0, 1);
reportGrid->addWidget(new QLabel(tr("Game ID:")), 1, 0);
if (gameId >= 0) {
gameIdLabel = new QLabel(QString::number(gameId));
gameIdLabel->setStyleSheet("font-weight: bold;");
reportGrid->addWidget(gameIdLabel, 1, 1);
} else {
gameIdEdit = new QLineEdit;
gameIdEdit->setPlaceholderText(tr("(Optional) Enter game ID if available"));
gameIdEdit->setToolTip(tr("If the report is related to a specific game, enter its ID."));
reportGrid->addWidget(gameIdEdit, 1, 1);
gameIdLabel = nullptr;
}
auto *categoryGroup = new QGroupBox(tr("Category"));
auto *categoryGrid = new QGridLayout(categoryGroup);
categoryBox = new QComboBox;
categoryBox->addItem(tr("Cheating / Unsporting behavior"), "cheating");
categoryBox->setItemData(categoryBox->count() - 1,
tr("Using external tools, card marked manipulation, or exploiting game bugs"),
Qt::ToolTipRole);
categoryBox->addItem(tr("Harassment / Abuse"), "harassment");
categoryBox->setItemData(categoryBox->count() - 1, tr("Threatening, bullying, or persistent unwanted contact"),
Qt::ToolTipRole);
categoryBox->addItem(tr("Hate speech"), "hate_speech");
categoryBox->setItemData(categoryBox->count() - 1,
tr("Discriminatory language targeting race, gender, religion, etc."), Qt::ToolTipRole);
categoryBox->addItem(tr("Spam"), "spam");
categoryBox->setItemData(categoryBox->count() - 1, tr("Repeated unwanted messages or advertisements"),
Qt::ToolTipRole);
categoryBox->addItem(tr("Other"), "other");
categoryBox->setItemData(categoryBox->count() - 1, tr("Any behavior not covered by the above categories"),
Qt::ToolTipRole);
categoryGrid->addWidget(new QLabel(tr("Category:")), 0, 0);
categoryGrid->addWidget(categoryBox, 0, 1);
auto *descGroup = new QGroupBox(tr("Description"));
auto *descLayout = new QVBoxLayout(descGroup);
descriptionEdit = new QTextEdit;
descriptionEdit->setPlaceholderText(
tr("Please describe what happened. Include dates, game details, or any evidence if available."));
descriptionEdit->setFixedHeight(120);
descLayout->addWidget(descriptionEdit);
auto *chatGroup = new QGroupBox(tr("Chat Log Context"));
auto *chatLayout = new QVBoxLayout(chatGroup);
chatLogEdit = new QTextEdit;
chatLogEdit->setReadOnly(true);
QFont monoFont("monospace");
monoFont.setStyleHint(QFont::Monospace);
const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize();
if (systemPointSize > 0) {
monoFont.setPointSize(systemPointSize);
}
chatLogEdit->setFont(monoFont);
if (!_autoChatLog.isEmpty()) {
chatLogEdit->setPlainText(_autoChatLog);
} else {
chatLogEdit->setPlaceholderText(tr("No chat context available (not triggered from chat)."));
}
chatLogEdit->setFixedHeight(100);
chatLayout->addWidget(chatLogEdit);
auto *chatNote = new QLabel(
tr("This chat log is captured from your local chat window and may not reflect the full conversation."));
chatNote->setWordWrap(true);
chatNote->setStyleSheet("color: palette(placeholderText);");
chatLayout->addWidget(chatNote);
buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submit Report"));
connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgReportUser::actSubmit);
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *layout = new QVBoxLayout(this);
layout->addWidget(infoLabel);
layout->addWidget(reportGroup);
layout->addWidget(categoryGroup);
layout->addWidget(descGroup);
layout->addWidget(chatGroup);
layout->addWidget(buttonBox);
}
void DlgReportUser::actSubmit()
{
const QString description = descriptionEdit->toPlainText().trimmed();
if (description.isEmpty()) {
QMessageBox::warning(this, tr("Missing description"), tr("Please describe what happened before submitting."));
return;
}
QMessageBox::StandardButton reply =
QMessageBox::question(this, tr("Confirm Report"),
tr("Submit report against %1 for %2?").arg(reportedUser, categoryBox->currentText()),
QMessageBox::Yes | QMessageBox::No);
if (reply != QMessageBox::Yes) {
return;
}
buttonBox->setEnabled(false);
buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submitting..."));
Command_Report cmd;
cmd.set_reported_user(reportedUser.toStdString());
cmd.set_category(categoryBox->currentData().toString().toStdString());
cmd.set_description(description.toStdString());
if (gameId >= 0) {
cmd.set_game_id(gameId);
} else if (gameIdEdit && !gameIdEdit->text().trimmed().isEmpty()) {
bool ok;
int manualGameId = gameIdEdit->text().trimmed().toInt(&ok);
if (ok && manualGameId > 0) {
cmd.set_game_id(manualGameId);
}
}
const QString chatLog = chatLogEdit->toPlainText().trimmed();
if (!chatLog.isEmpty()) {
cmd.set_chat_log(chatLog.toStdString());
}
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &DlgReportUser::reportResponse);
client->sendCommand(pend);
}
void DlgReportUser::reportResponse(const Response &response)
{
buttonBox->setEnabled(true);
buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submit Report"));
if (response.response_code() == Response::RespOk) {
QMessageBox::information(this, tr("Report Submitted"),
tr("Your report has been submitted and will be reviewed by a moderator. Thank you."));
accept();
} else if (response.response_code() == Response::RespTooManyRequests) {
QMessageBox::warning(this, tr("Submission Failed"),
tr("You have reached the daily report limit. Please try again later."));
} else if (response.response_code() == Response::RespNameNotFound) {
QMessageBox::warning(
this, tr("Submission Failed"),
tr("The reported user could not be found. Guests (unregistered users) cannot be reported."));
} else {
QMessageBox::warning(this, tr("Submission Failed"), tr("Failed to submit report. Please try again."));
}
}

View file

@ -0,0 +1,42 @@
#ifndef COCKATRICE_DLG_REPORT_USER_H
#define COCKATRICE_DLG_REPORT_USER_H
#include <QDialog>
#include <libcockatrice/protocol/pb/response.pb.h>
class AbstractClient;
class QComboBox;
class QDialogButtonBox;
class QLineEdit;
class QTextEdit;
class QLabel;
class DlgReportUser : public QDialog
{
Q_OBJECT
public:
DlgReportUser(AbstractClient *_client,
const QString &_reportedUser,
int _gameId = -1,
const QString &_autoChatLog = QString(),
QWidget *parent = nullptr);
private slots:
void actSubmit();
void reportResponse(const Response &response);
private:
AbstractClient *client;
QString reportedUser;
int gameId;
QLabel *reportedUserLabel;
QLabel *gameIdLabel = nullptr;
QLineEdit *gameIdEdit = nullptr;
QComboBox *categoryBox;
QTextEdit *descriptionEdit;
QTextEdit *chatLogEdit;
QDialogButtonBox *buttonBox;
};
#endif // COCKATRICE_DLG_REPORT_USER_H

View file

@ -32,6 +32,7 @@ BannerWidget::BannerWidget(QWidget *parent, const QString &text, Qt::Orientation
// Set minimum height for the widget
setMinimumHeight(50);
setMaximumHeight(100);
connect(this, &BannerWidget::buddyVisibilityChanged, this, &BannerWidget::toggleBuddyVisibility);
updateDropdownIconState();

View file

@ -0,0 +1,49 @@
#ifndef COCKATRICE_HOME_TAB_BUTTON_COLOR_H
#define COCKATRICE_HOME_TAB_BUTTON_COLOR_H
#include <QList>
namespace HomeTabButtonColor
{
/**
* @brief Where to get the colors for the home tab buttons from
*/
enum Source
{
Automatic, ///< Extract color from background, or use theme color if no background
FromBackground, ///< Always extract color from background
};
struct Entry
{
Source source;
const char *trKey; ///< key for translation
};
inline QList<Entry> all()
{
static QList<Entry> entries = {{Automatic, QT_TR_NOOP("Automatic")},
{FromBackground, QT_TR_NOOP("Extract from background")}};
return entries;
}
/**
* Safely converts an int into the corresponding Source.
*
* @param value The int value
* @return The Source. Returns Source::Automatic if the value is not within range
*/
inline Source intToSource(int value)
{
if (value > FromBackground) {
return Automatic; // default
}
return static_cast<Source>(value);
}
} // namespace HomeTabButtonColor
#endif // COCKATRICE_HOME_TAB_BUTTON_COLOR_H

View file

@ -4,8 +4,10 @@
#include "../../../interface/widgets/tabs/tab_supervisor.h"
#include "../../theme_manager.h"
#include "../../window_main.h"
#include "../cards/art_crop_attribution.h"
#include "background_sources.h"
#include "home_styled_button.h"
#include "home_tab_button_color.h"
#include <QGroupBox>
#include <QPainter>
@ -24,7 +26,7 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
backgroundSourceCard = new CardInfoPictureArtCropWidget(this);
gradientColors = extractDominantColors(background);
gradientColors = determineButtonColor();
layout->addWidget(createButtons(), 1, 1, Qt::AlignVCenter | Qt::AlignHCenter);
@ -54,6 +56,8 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor)
&HomeWidget::initializeBackgroundFromSource);
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this,
&HomeWidget::updateButtonsToBackgroundColor);
connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabButtonColorChanged, this,
&HomeWidget::updateButtonsToBackgroundColor);
}
void HomeWidget::initializeBackgroundFromSource()
@ -96,6 +100,34 @@ void HomeWidget::loadBackgroundSourceDeck()
backgroundSourceDeck = deckOpt.has_value() ? deckOpt.value().deckList : DeckList();
}
static bool isDefaultBackgroundAndTheme()
{
QString sourceId = SettingsCache::instance().appearance().getHomeTabBackgroundSource();
return themeManager->isBuiltInTheme() && BackgroundSources::fromId(sourceId) == BackgroundSources::Theme;
}
QPair<QColor, QColor> HomeWidget::determineButtonColor() const
{
static QPair defaultColor = {QColor::fromRgb(20, 140, 60), QColor::fromRgb(120, 200, 80)};
auto colorSource =
HomeTabButtonColor::intToSource(SettingsCache::instance().appearance().getHomeTabButtonColorSourceIndex());
switch (colorSource) {
case HomeTabButtonColor::Automatic: {
if (isDefaultBackgroundAndTheme()) {
return defaultColor;
} else {
return extractDominantColors(background);
}
}
case HomeTabButtonColor::FromBackground:
return extractDominantColors(background);
}
return defaultColor;
}
void HomeWidget::setRandomCard(ExactCard &newCard)
{
static constexpr int ATTEMPTS = 10;
@ -170,7 +202,7 @@ void HomeWidget::updateBackgroundProperties()
void HomeWidget::updateButtonsToBackgroundColor()
{
gradientColors = extractDominantColors(background);
gradientColors = determineButtonColor();
for (HomeStyledButton *button : findChildren<HomeStyledButton *>()) {
button->updateStylesheet(gradientColors);
button->update();
@ -265,11 +297,6 @@ void HomeWidget::updateConnectButton(const ClientStatus status)
QPair<QColor, QColor> HomeWidget::extractDominantColors(const QPixmap &pixmap)
{
if (themeManager->isBuiltInTheme() && SettingsCache::instance().appearance().getHomeTabBackgroundSource() ==
BackgroundSources::toId(BackgroundSources::Theme)) {
return QPair<QColor, QColor>(QColor::fromRgb(20, 140, 60), QColor::fromRgb(120, 200, 80));
}
// Step 1: Downscale image for performance
QImage image = pixmap.toImage()
.scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)
@ -341,8 +368,9 @@ void HomeWidget::paintEvent(QPaintEvent *event)
QColor semiTransparentBlack(0, 0, 0, static_cast<int>(255 * 0.33));
painter.fillPath(roundedRectPath, semiTransparentBlack);
// Card name overlay (bottom-right)
// Card name overlay (above the attribution, bottom-right)
QString cardName;
QString attribution;
ExactCard card = backgroundSourceCard->getCard();
if (card) {
cardName = card.getCardPtr()->getName();
@ -350,8 +378,27 @@ void HomeWidget::paintEvent(QPaintEvent *event)
cardName += " (" + card.getPrinting().getSet()->getCorrectedShortName() + ") " +
card.getPrinting().getProperty("num");
}
attribution = buildArtAttribution(card);
}
// Scryfall requires artist attribution wherever card art is shown cropped.
// Pin it to the bottom-right corner, using the same font as the card name pill,
// and align its right edge with the card name pill's right edge.
constexpr int margin = 15;
constexpr qreal attributionMargin = 4.0;
QFont attributionFont = painter.font();
attributionFont.setPointSize(14);
attributionFont.setBold(true);
painter.setFont(attributionFont);
// paintArtAttribution insets the pill 4px from the given rect's right edge,
// so nudge the rect's right edge to land exactly on the pill's right edge.
QRectF attributionArea = rect();
attributionArea.setRight(width() - margin + attributionMargin);
const QRectF attributionRect = paintArtAttribution(painter, attributionArea, attribution);
// Card name bubble above the attribution (when enabled).
if (!cardName.isEmpty() && SettingsCache::instance().appearance().getHomeTabDisplayCardName()) {
QFont font = painter.font();
font.setPointSize(14);
@ -360,23 +407,26 @@ void HomeWidget::paintEvent(QPaintEvent *event)
QFontMetrics fm(font);
constexpr int padding = 10;
constexpr int margin = 15;
QRect textRect = fm.boundingRect(cardName);
QRect bgRect(width() - textRect.width() - padding * 2 - margin,
height() - textRect.height() - padding * 2 - margin, textRect.width() + padding * 2,
textRect.height() + padding * 2);
int bubbleBottom = height() - margin;
if (!attributionRect.isEmpty()) {
bubbleBottom = attributionRect.top() - 6;
}
const QRect nameBubbleRect(width() - textRect.width() - padding * 2 - margin,
bubbleBottom - textRect.height() - padding * 2, textRect.width() + padding * 2,
textRect.height() + padding * 2);
// Background bubble
painter.setPen(Qt::NoPen);
painter.setBrush(QColor(0, 0, 0, 160));
painter.drawRoundedRect(bgRect, 8, 8);
painter.drawRoundedRect(nameBubbleRect, 8, 8);
// Text
painter.setPen(Qt::white);
painter.drawText(bgRect.adjusted(padding, padding, -padding, -padding), Qt::AlignRight | Qt::AlignVCenter,
cardName);
painter.drawText(nameBubbleRect.adjusted(padding, padding, -padding, -padding),
Qt::AlignRight | Qt::AlignVCenter, cardName);
}
QWidget::paintEvent(event);

View file

@ -23,7 +23,7 @@ class HomeWidget : public QWidget
public:
HomeWidget(QWidget *parent, TabSupervisor *tabSupervisor);
void updateRandomCard();
QPair<QColor, QColor> extractDominantColors(const QPixmap &pixmap);
static QPair<QColor, QColor> extractDominantColors(const QPixmap &pixmap);
public slots:
void paintEvent(QPaintEvent *event) override;
@ -47,6 +47,7 @@ private:
void setRandomCard(ExactCard &newCard);
void loadBackgroundSourceDeck();
QPair<QColor, QColor> determineButtonColor() const;
};
#endif // HOME_WIDGET_H

View file

@ -0,0 +1,250 @@
#ifndef BANNER_SHADER_CONFIG_H
#define BANNER_SHADER_CONFIG_H
#include <QColor>
#include <QObject>
/**
* Uniform values fed to brand_banner.frag, exposed to QML as the
* "bannerConfig" context property.
*
* Two independent "banks" (A/B) each carry their own mode/speed/seed so
* BrandBanner.qml can render both simultaneously and crossfade between
* them via opacity -- see frontIsA. The shared palette (colorA/colorB/
* accent) and clock (time/aspect) apply to both banks identically, since
* only the foreground motif changes between onboarding pages, never the
* brand palette.
*
* Deliberately plain `property` (not `required property`) on the QML side
* -- a required-property shadowing bug bit the home-screen particle
* background before, and there's no reason to reintroduce that risk here.
*/
class BannerShaderConfig : public QObject
{
Q_OBJECT
Q_PROPERTY(qreal time READ time WRITE setTime NOTIFY timeChanged)
Q_PROPERTY(qreal aspect READ aspect WRITE setAspect NOTIFY aspectChanged)
Q_PROPERTY(qreal modeA READ modeA WRITE setModeA NOTIFY modeAChanged)
Q_PROPERTY(qreal speedA READ speedA WRITE setSpeedA NOTIFY speedAChanged)
Q_PROPERTY(qreal seedA READ seedA WRITE setSeedA NOTIFY seedAChanged)
Q_PROPERTY(qreal modeB READ modeB WRITE setModeB NOTIFY modeBChanged)
Q_PROPERTY(qreal speedB READ speedB WRITE setSpeedB NOTIFY speedBChanged)
Q_PROPERTY(qreal seedB READ seedB WRITE setSeedB NOTIFY seedBChanged)
Q_PROPERTY(bool frontIsA READ frontIsA WRITE setFrontIsA NOTIFY frontIsAChanged)
Q_PROPERTY(QColor colorA READ colorA WRITE setColorA NOTIFY colorAChanged)
Q_PROPERTY(QColor colorB READ colorB WRITE setColorB NOTIFY colorBChanged)
Q_PROPERTY(QColor accent READ accent WRITE setAccent NOTIFY accentChanged)
Q_PROPERTY(bool logoVisible READ logoVisible WRITE setLogoVisible NOTIFY logoVisibleChanged)
Q_PROPERTY(qreal logoGlow READ logoGlow WRITE setLogoGlow NOTIFY logoGlowChanged)
public:
explicit BannerShaderConfig(QObject *parent = nullptr) : QObject(parent)
{
}
qreal time() const
{
return m_time;
}
void setTime(qreal v)
{
if (v != m_time) {
m_time = v;
emit timeChanged();
}
}
qreal aspect() const
{
return m_aspect;
}
void setAspect(qreal v)
{
if (v != m_aspect) {
m_aspect = v;
emit aspectChanged();
}
}
qreal modeA() const
{
return m_modeA;
}
void setModeA(qreal v)
{
if (v != m_modeA) {
m_modeA = v;
emit modeAChanged();
}
}
qreal speedA() const
{
return m_speedA;
}
void setSpeedA(qreal v)
{
if (v != m_speedA) {
m_speedA = v;
emit speedAChanged();
}
}
qreal seedA() const
{
return m_seedA;
}
void setSeedA(qreal v)
{
if (v != m_seedA) {
m_seedA = v;
emit seedAChanged();
}
}
qreal modeB() const
{
return m_modeB;
}
void setModeB(qreal v)
{
if (v != m_modeB) {
m_modeB = v;
emit modeBChanged();
}
}
qreal speedB() const
{
return m_speedB;
}
void setSpeedB(qreal v)
{
if (v != m_speedB) {
m_speedB = v;
emit speedBChanged();
}
}
qreal seedB() const
{
return m_seedB;
}
void setSeedB(qreal v)
{
if (v != m_seedB) {
m_seedB = v;
emit seedBChanged();
}
}
bool frontIsA() const
{
return m_frontIsA;
}
void setFrontIsA(bool v)
{
if (v != m_frontIsA) {
m_frontIsA = v;
emit frontIsAChanged();
}
}
QColor colorA() const
{
return m_colorA;
}
void setColorA(const QColor &c)
{
if (c != m_colorA) {
m_colorA = c;
emit colorAChanged();
}
}
QColor colorB() const
{
return m_colorB;
}
void setColorB(const QColor &c)
{
if (c != m_colorB) {
m_colorB = c;
emit colorBChanged();
}
}
QColor accent() const
{
return m_accent;
}
void setAccent(const QColor &c)
{
if (c != m_accent) {
m_accent = c;
emit accentChanged();
}
}
bool logoVisible() const
{
return m_logoVisible;
}
void setLogoVisible(bool v)
{
if (v != m_logoVisible) {
m_logoVisible = v;
emit logoVisibleChanged();
}
}
qreal logoGlow() const
{
return m_logoGlow;
}
void setLogoGlow(qreal v)
{
if (v != m_logoGlow) {
m_logoGlow = v;
emit logoGlowChanged();
}
}
signals:
void timeChanged();
void aspectChanged();
void modeAChanged();
void speedAChanged();
void seedAChanged();
void modeBChanged();
void speedBChanged();
void seedBChanged();
void frontIsAChanged();
void colorAChanged();
void colorBChanged();
void accentChanged();
void logoVisibleChanged();
void logoGlowChanged();
private:
qreal m_time = 0.0;
qreal m_aspect = 16.0 / 9.0;
qreal m_modeA = 0.0;
qreal m_speedA = 1.0;
qreal m_seedA = 0.0;
qreal m_modeB = 0.0;
qreal m_speedB = 1.0;
qreal m_seedB = 0.0;
bool m_frontIsA = true;
QColor m_colorA{0x1A, 0x1A, 0x20};
QColor m_colorB{0x0E, 0x0E, 0x12};
QColor m_accent{0x8B, 0xDD, 0x6B};
bool m_logoVisible = false;
qreal m_logoGlow = 1.0;
};
#endif // BANNER_SHADER_CONFIG_H

View file

@ -0,0 +1,218 @@
#include "first_run_wizard.h"
#include "first_run_wizard_page.h"
#include "pages/account_setup_page.h"
#include "pages/card_database_setup_page.h"
#include "pages/finish_page.h"
#include "pages/preferences_setup_page.h"
#include "pages/theme_setup_page.h"
#include "pages/welcome_page.h"
#include "shader_banner_widget.h"
#include "step_indicator_widget.h"
#include <QCloseEvent>
#include <QEvent>
#include <QFont>
#include <QHBoxLayout>
#include <QLabel>
#include <QPushButton>
#include <QStackedWidget>
#include <QVBoxLayout>
FirstRunWizard::FirstRunWizard(QWidget *parent) : QDialog(parent)
{
setWindowFlag(Qt::WindowContextHelpButtonHint, false);
setMinimumSize(640, 490);
resize(720, 550);
bannerHost = new BannerHost(this);
titleLabel = new QLabel(this);
QFont titleFont = titleLabel->font();
titleFont.setPointSizeF(titleFont.pointSizeF() * 1.4);
titleFont.setBold(true);
titleLabel->setFont(titleFont);
subtitleLabel = new QLabel(this);
subtitleLabel->setWordWrap(true);
stack = new QStackedWidget(this);
stepIndicator = new StepIndicatorWidget(this);
backButton = new QPushButton(this);
skipButton = new QPushButton(this);
nextButton = new QPushButton(this);
nextButton->setDefault(true);
connect(backButton, &QPushButton::clicked, this, &FirstRunWizard::goBack);
connect(skipButton, &QPushButton::clicked, this, &FirstRunWizard::skip);
connect(nextButton, &QPushButton::clicked, this, &FirstRunWizard::goNext);
auto *headerLayout = new QVBoxLayout;
headerLayout->setContentsMargins(0, 0, 0, 0);
headerLayout->addWidget(bannerHost);
headerLayout->addSpacing(12);
headerLayout->addWidget(titleLabel);
headerLayout->addWidget(subtitleLabel);
auto *navLayout = new QHBoxLayout;
navLayout->addWidget(backButton);
navLayout->addWidget(skipButton);
navLayout->addStretch();
navLayout->addWidget(stepIndicator);
navLayout->addStretch();
navLayout->addWidget(nextButton);
auto *root = new QVBoxLayout(this);
root->addLayout(headerLayout);
root->addSpacing(8);
root->addWidget(stack, 1);
root->addSpacing(8);
root->addLayout(navLayout);
auto *welcome = new WelcomePage(this);
auto *cardDb = new CardDatabaseSetupPage(this);
auto *theme = new ThemeSetupPage(this);
auto *account = new AccountSetupPage(this);
auto *prefs = new PreferencesSetupPage(this);
auto *finishPg = new FinishPage(this);
cardDatabasePage = cardDb;
connect(cardDb, &CardDatabaseSetupPage::updateRequested, this, &FirstRunWizard::cardDatabaseUpdateRequested);
connect(cardDb, &CardDatabaseSetupPage::manualSetupRequested, this,
&FirstRunWizard::manualCardDatabaseSetupRequested);
connect(account, &AccountSetupPage::registerRequested, this, &FirstRunWizard::registerRequested);
connect(account, &AccountSetupPage::connectRequested, this, &FirstRunWizard::connectRequested);
connect(cardDb, &CardDatabaseSetupPage::advanceRequested, this, [this] {
if (stack->currentWidget() == cardDatabasePage) {
showPage(currentIndex + 1);
}
});
addPage(welcome);
addPage(cardDb);
addPage(theme);
addPage(account);
addPage(prefs);
addPage(finishPg);
stepIndicator->setStepCount(pages.count());
retranslateUi();
showPage(0);
}
void FirstRunWizard::addPage(FirstRunWizardPage *page)
{
pages.append(page);
stack->addWidget(page);
connect(page, &FirstRunWizardPage::completeChanged, this, &FirstRunWizard::updateChrome);
}
void FirstRunWizard::showPage(int index)
{
if (index < 0 || index >= pages.count()) {
return;
}
currentIndex = index;
stack->setCurrentIndex(index);
pages[index]->initializePage();
stepIndicator->setCurrentStep(index);
static const QList<BannerHost::Motif> motifs = {
BannerHost::Motif::Welcome, BannerHost::Motif::CardDatabase, BannerHost::Motif::Theming,
BannerHost::Motif::Account, BannerHost::Motif::Preferences, BannerHost::Motif::Finish,
};
if (index < motifs.size()) {
bannerHost->setMotif(motifs[index]);
}
titleLabel->setText(pages[index]->stepTitle());
subtitleLabel->setText(pages[index]->stepSubtitle());
subtitleLabel->setVisible(!pages[index]->stepSubtitle().isEmpty());
updateChrome();
}
void FirstRunWizard::updateChrome()
{
if (currentIndex < 0) {
return;
}
FirstRunWizardPage *page = pages[currentIndex];
const bool isLast = (currentIndex == pages.count() - 1);
backButton->setVisible(currentIndex > 0);
skipButton->setVisible(page->isSkippable());
nextButton->setEnabled(page->isComplete());
QString customText = page->nextButtonText();
if (!customText.isEmpty()) {
nextButton->setText(customText);
} else {
nextButton->setText(isLast ? tr("Finish") : tr("Next"));
}
}
void FirstRunWizard::goNext()
{
FirstRunWizardPage *page = pages[currentIndex];
if (!page->validatePage() || !page->handleNextClick()) {
return;
}
if (currentIndex == pages.count() - 1) {
finish();
return;
}
showPage(currentIndex + 1);
}
void FirstRunWizard::goBack()
{
showPage(currentIndex - 1);
}
void FirstRunWizard::skip()
{
showPage(currentIndex + 1);
}
void FirstRunWizard::onCardDatabaseUpdateFinished(bool success)
{
if (cardDatabasePage) {
cardDatabasePage->onUpdateFinished(success);
}
}
void FirstRunWizard::finish()
{
accept();
}
void FirstRunWizard::closeEvent(QCloseEvent *event)
{
// Every step persists its own choice as it's made, so closing early
// isn't destructive -- treat it exactly like reaching the end.
QDialog::closeEvent(event);
}
void FirstRunWizard::changeEvent(QEvent *event)
{
if (event->type() == QEvent::LanguageChange) {
retranslateUi();
}
QDialog::changeEvent(event);
}
void FirstRunWizard::retranslateUi()
{
setWindowTitle(tr("Welcome to Cockatrice"));
backButton->setText(tr("Back"));
skipButton->setText(tr("Skip"));
for (FirstRunWizardPage *page : std::as_const(pages)) {
page->retranslateUi();
}
if (currentIndex >= 0) {
titleLabel->setText(pages[currentIndex]->stepTitle());
subtitleLabel->setText(pages[currentIndex]->stepSubtitle());
}
updateChrome();
}

View file

@ -0,0 +1,71 @@
#ifndef FIRST_RUN_WIZARD_H
#define FIRST_RUN_WIZARD_H
#include <QDialog>
#include <QList>
class BannerHost;
class FirstRunWizardPage;
class StepIndicatorWidget;
class CardDatabaseSetupPage;
class QLabel;
class QPushButton;
class QStackedWidget;
/** @brief Polished first-run onboarding flow: card database setup, theme
* selection, server account setup, and a handful of key preferences.
*
* Deliberately ignorant of network/registration/download internals --
* pages that need them emit request signals for MainWindow to fulfill.
* Every choice is written to SettingsCache as it's made (via the pages
* themselves, same as AppearanceSettingsPage does), so "Skip" or closing
* the window never discards anything already confirmed. */
class FirstRunWizard : public QDialog
{
Q_OBJECT
public:
explicit FirstRunWizard(QWidget *parent = nullptr);
signals:
void registerRequested();
void connectRequested();
void cardDatabaseUpdateRequested();
void manualCardDatabaseSetupRequested();
public slots:
/** @brief Forwarded from MainWindow once the background card database update process exits. */
void onCardDatabaseUpdateFinished(bool success);
protected:
void closeEvent(QCloseEvent *event) override;
void changeEvent(QEvent *event) override;
private slots:
void goNext();
void goBack();
void skip();
void updateChrome();
private:
void addPage(FirstRunWizardPage *page);
void showPage(int index);
void retranslateUi();
void finish();
QStackedWidget *stack;
StepIndicatorWidget *stepIndicator;
BannerHost *bannerHost;
QLabel *titleLabel;
QLabel *subtitleLabel;
QPushButton *backButton;
QPushButton *skipButton;
QPushButton *nextButton;
CardDatabaseSetupPage *cardDatabasePage = nullptr;
QList<FirstRunWizardPage *> pages;
int currentIndex = -1;
};
#endif // FIRST_RUN_WIZARD_H

View file

@ -0,0 +1 @@
#include "first_run_wizard_page.h"

View file

@ -0,0 +1,75 @@
#ifndef FIRST_RUN_WIZARD_PAGE_H
#define FIRST_RUN_WIZARD_PAGE_H
#include <QWidget>
/** @brief Base class for a single step of FirstRunWizard.
*
* QWidget-based rather than QWizardPage-based: FirstRunWizard is a
* QDialog + QStackedWidget shell (not a QWizard) so it can own the
* banner/step-dot chrome that QWizard's native styles don't give us
* consistent control over. Naming mirrors OracleWizardPage for
* familiarity only -- the two hierarchies are unrelated. */
class FirstRunWizardPage : public QWidget
{
Q_OBJECT
public:
explicit FirstRunWizardPage(QWidget *parent = nullptr) : QWidget(parent)
{
}
/** @brief Called every time the page becomes visible, including navigating back to it. */
virtual void initializePage()
{
}
/** @brief Called before advancing past this page. Return false to block navigation;
the page itself is responsible for telling the user why. */
virtual bool validatePage()
{
return true;
}
/** @brief Whether Next/Finish should currently be enabled. Pages doing async work
can flip this mid-step; emit completeChanged() when they do. */
virtual bool isComplete() const
{
return true;
}
/** @brief Whether the wizard's "Skip" button should be offered on this page. */
virtual bool isSkippable() const
{
return false;
}
virtual QString stepTitle() const = 0;
virtual QString stepSubtitle() const
{
return {};
}
/** @brief Override to replace the "Next"/"Finish" button text on this page.
Return an empty string to use the default label. */
virtual QString nextButtonText() const
{
return {};
}
/** @brief Called when the user presses the Next button. Return true to allow
advancing to the next page, false to stay on this page (e.g. to
trigger an async action first). */
virtual bool handleNextClick()
{
return true;
}
virtual void retranslateUi() = 0;
signals:
void completeChanged();
void advanceRequested();
};
#endif // FIRST_RUN_WIZARD_PAGE_H

View file

@ -0,0 +1,56 @@
#include "account_setup_page.h"
#include <QLabel>
#include <QPushButton>
#include <QVBoxLayout>
AccountSetupPage::AccountSetupPage(QWidget *parent) : FirstRunWizardPage(parent)
{
bodyLabel = new QLabel(this);
bodyLabel->setWordWrap(true);
bodyLabel->setAlignment(Qt::AlignCenter);
registerButton = new QPushButton(this);
connectButton = new QPushButton(this);
skipHintLabel = new QLabel(this);
skipHintLabel->setWordWrap(true);
skipHintLabel->setAlignment(Qt::AlignCenter);
connect(registerButton, &QPushButton::clicked, this, &AccountSetupPage::registerRequested);
connect(connectButton, &QPushButton::clicked, this, &AccountSetupPage::connectRequested);
auto *layout = new QVBoxLayout(this);
layout->addStretch();
layout->addWidget(bodyLabel);
layout->addSpacing(16);
layout->addWidget(registerButton, 0, Qt::AlignHCenter);
layout->addWidget(connectButton, 0, Qt::AlignHCenter);
layout->addSpacing(16);
layout->addWidget(skipHintLabel);
layout->addStretch();
retranslateUi();
}
bool AccountSetupPage::isSkippable() const
{
return true;
}
QString AccountSetupPage::stepTitle() const
{
return tr("Join a Server");
}
QString AccountSetupPage::stepSubtitle() const
{
return tr("Optional — you can always do this later from the menu.");
}
void AccountSetupPage::retranslateUi()
{
bodyLabel->setText(tr("Playing online needs a server account."));
registerButton->setText(tr("Register a new account…"));
connectButton->setText(tr("I already have one — Connect…"));
skipHintLabel->setText(tr("Just want to play locally? Skip this and connect whenever you're ready."));
}

View file

@ -0,0 +1,38 @@
#ifndef ACCOUNT_SETUP_PAGE_H
#define ACCOUNT_SETUP_PAGE_H
#include "../first_run_wizard_page.h"
class QLabel;
class QPushButton;
/** @brief First-run account step. Does NOT embed DlgRegister's fields: they exist
* to be handed to ConnectionController's network registration flow, which
* this wizard has no visibility into. Reimplementing the fields here
* without that wiring would look functional and silently do nothing --
* worse than reuse. So: a friendly landing spot that opens the *existing*
* DlgRegister / connect flow via signals FirstRunWizard forwards. */
class AccountSetupPage : public FirstRunWizardPage
{
Q_OBJECT
public:
explicit AccountSetupPage(QWidget *parent = nullptr);
bool isSkippable() const override;
QString stepTitle() const override;
QString stepSubtitle() const override;
void retranslateUi() override;
signals:
void registerRequested();
void connectRequested();
private:
QLabel *bodyLabel;
QPushButton *registerButton;
QPushButton *connectButton;
QLabel *skipHintLabel;
};
#endif // ACCOUNT_SETUP_PAGE_H

View file

@ -0,0 +1,314 @@
#include "card_database_setup_page.h"
#include "../../client/settings/cache_settings.h"
#include <QComboBox>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QProgressBar>
#include <QPushButton>
#include <QSettings>
#include <QSpinBox>
#include <QTimer>
#include <QUrl>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/updates_settings.h>
CardDatabaseSetupPage::CardDatabaseSetupPage(QWidget *parent) : FirstRunWizardPage(parent)
{
statusLabel = new QLabel(this);
statusLabel->setWordWrap(true);
statusLabel->setAlignment(Qt::AlignCenter);
progressBar = new QProgressBar(this);
progressBar->setRange(0, 0);
progressBar->setTextVisible(false);
progressBar->setFixedWidth(280);
retryButton = new QPushButton(this);
manualButton = new QPushButton(this);
connect(retryButton, &QPushButton::clicked, this, [this] {
setState(State::Running);
emit updateRequested();
});
connect(manualButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::manualSetupRequested);
// ── Advanced: custom download source ───────────────────────────────
advancedToggleButton = new QPushButton(this);
advancedToggleButton->setCheckable(true);
advancedToggleButton->setChecked(false);
advancedToggleButton->setFlat(true);
advancedToggleButton->setStyleSheet("QPushButton { text-align: left; padding: 5px 12px; font-weight: bold; }"
"QPushButton:checked { }");
advancedPanel = new QWidget(this);
advancedPanel->setVisible(false);
urlLineEdit = new QLineEdit(advancedPanel);
urlHintLabel = new QLabel(advancedPanel);
urlHintLabel->setWordWrap(true);
restoreDefaultUrlButton = new QPushButton(advancedPanel);
applyAndRetryButton = new QPushButton(advancedPanel);
connect(advancedToggleButton, &QPushButton::toggled, this, &CardDatabaseSetupPage::onToggleAdvanced);
connect(restoreDefaultUrlButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::onRestoreDefaultUrl);
connect(applyAndRetryButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::onApplyCustomUrl);
auto *advancedButtonRow = new QHBoxLayout;
advancedButtonRow->addWidget(restoreDefaultUrlButton);
advancedButtonRow->addStretch();
advancedButtonRow->addWidget(applyAndRetryButton);
auto *advancedLayout = new QVBoxLayout(advancedPanel);
advancedLayout->setContentsMargins(12, 4, 12, 4);
advancedLayout->addWidget(urlLineEdit);
advancedLayout->addWidget(urlHintLabel);
advancedLayout->addLayout(advancedButtonRow);
// ── Startup card update check ───────────────────────────────────────
auto &upd = SettingsCache::instance().updates();
const auto updateBehavior = [this] {
auto &u = SettingsCache::instance().updates();
int idx = startupBehaviorCombo->currentIndex();
u.setStartupCardUpdateCheckPromptForUpdate(idx == 1);
u.setStartupCardUpdateCheckAlwaysUpdate(idx == 2);
};
startupBehaviorLabel = new QLabel(this);
startupBehaviorCombo = new QComboBox(this);
startupBehaviorCombo->addItem(QString()); // placeholder, filled in retranslateUi
startupBehaviorCombo->addItem(QString());
startupBehaviorCombo->addItem(QString());
if (upd.getStartupCardUpdateCheckPromptForUpdate()) {
startupBehaviorCombo->setCurrentIndex(1);
} else if (upd.getStartupCardUpdateCheckAlwaysUpdate()) {
startupBehaviorCombo->setCurrentIndex(2);
} else {
startupBehaviorCombo->setCurrentIndex(0);
}
connect(startupBehaviorCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, updateBehavior);
checkIntervalLabel = new QLabel(this);
checkIntervalSpinBox = new QSpinBox(this);
checkIntervalSpinBox->setMinimum(1);
checkIntervalSpinBox->setMaximum(30);
checkIntervalSpinBox->setValue(upd.getCardUpdateCheckInterval());
connect(checkIntervalSpinBox, QOverload<int>::of(&QSpinBox::valueChanged), &upd,
&UpdatesSettings::setCardUpdateCheckInterval);
auto *checkGrid = new QGridLayout;
checkGrid->addWidget(startupBehaviorLabel, 0, 0);
checkGrid->addWidget(startupBehaviorCombo, 0, 1);
checkGrid->addWidget(checkIntervalLabel, 1, 0);
checkGrid->addWidget(checkIntervalSpinBox, 1, 1);
auto *layout = new QVBoxLayout(this);
layout->addStretch();
layout->addWidget(statusLabel);
layout->addSpacing(12);
layout->addWidget(progressBar, 0, Qt::AlignHCenter);
layout->addSpacing(12);
layout->addWidget(retryButton, 0, Qt::AlignHCenter);
layout->addWidget(manualButton, 0, Qt::AlignHCenter);
layout->addSpacing(16);
layout->addWidget(advancedToggleButton);
layout->addWidget(advancedPanel);
layout->addSpacing(8);
layout->addLayout(checkGrid);
layout->addStretch();
retranslateUi();
}
bool CardDatabaseSetupPage::alreadyHaveDatabase() const
{
return CardDatabaseManager::getInstance()->getCardList().count() > 0;
}
QString CardDatabaseSetupPage::oracleSettingsFilePath() const
{
return SettingsCache::instance().getSettingsPath() + "oracle.ini";
}
QString CardDatabaseSetupPage::readCustomUrl() const
{
QSettings oracleSettings(oracleSettingsFilePath(), QSettings::IniFormat);
return oracleSettings.value("allsetsurl").toString();
}
void CardDatabaseSetupPage::writeCustomUrl(const QString &url)
{
QSettings oracleSettings(oracleSettingsFilePath(), QSettings::IniFormat);
if (url.isEmpty()) {
oracleSettings.remove("allsetsurl");
} else {
oracleSettings.setValue("allsetsurl", url);
}
}
void CardDatabaseSetupPage::initializePage()
{
urlLineEdit->setText(readCustomUrl());
if (state != State::NotStarted) {
return;
}
if (alreadyHaveDatabase()) {
setState(State::Succeeded);
return;
}
// Don't auto-download — wait for the user to press "Download".
setState(State::NotStarted);
statusLabel->setText(tr("Press Download to fetch the card database, or Skip to do it later."));
}
void CardDatabaseSetupPage::onUpdateFinished(bool success)
{
setState(success ? State::Succeeded : State::Failed);
if (success) {
emit advanceRequested();
}
}
QString CardDatabaseSetupPage::nextButtonText() const
{
return state == State::NotStarted ? tr("Download") : QString();
}
bool CardDatabaseSetupPage::handleNextClick()
{
if (state == State::NotStarted) {
setState(State::Running);
emit updateRequested();
return false;
}
return true;
}
void CardDatabaseSetupPage::onToggleAdvanced(bool open)
{
advancedToggleButton->setText(open ? tr("▼ Advanced: custom download source")
: tr("▶ Advanced: custom download source"));
advancedPanel->setVisible(open);
QWidget *wizardWindow = window();
if (!wizardWindow) {
return;
}
if (open) {
windowSizeBeforeExpansion = wizardWindow->size();
QTimer::singleShot(0, this, [wizardWindow] {
wizardWindow->resize(wizardWindow->size().expandedTo(wizardWindow->sizeHint()));
});
} else {
QTimer::singleShot(0, this, [this, wizardWindow] {
wizardWindow->resize(wizardWindow->size().boundedTo(windowSizeBeforeExpansion));
});
}
}
void CardDatabaseSetupPage::onApplyCustomUrl()
{
const QString text = urlLineEdit->text().trimmed();
if (!text.isEmpty()) {
const QUrl url = QUrl::fromUserInput(text);
if (!url.isValid()) {
QMessageBox::warning(this, tr("Invalid URL"),
tr("That doesn't look like a valid URL. Double-check it and try again, "
"or clear the field to use the default source."));
return;
}
}
writeCustomUrl(text);
setState(State::Running);
emit updateRequested();
}
void CardDatabaseSetupPage::onRestoreDefaultUrl()
{
urlLineEdit->clear();
writeCustomUrl(QString());
}
void CardDatabaseSetupPage::setState(State newState)
{
state = newState;
progressBar->setVisible(state == State::Running);
retryButton->setVisible(state == State::Failed);
manualButton->setVisible(state == State::Failed);
applyAndRetryButton->setEnabled(state != State::Running);
switch (state) {
case State::NotStarted:
statusLabel->setText(tr("Press Download to fetch the card database, or Skip to do it later."));
break;
case State::Running:
statusLabel->setText(tr("Downloading the latest card database…"));
break;
case State::Succeeded:
statusLabel->setText(tr("Card database ready ✓"));
break;
case State::Failed:
statusLabel->setText(
tr("Couldn't download the card database automatically. Check your connection and retry, "
"set it up manually, or skip this for now — you can do it later from the Card Database menu."));
break;
}
emit completeChanged();
}
bool CardDatabaseSetupPage::isComplete() const
{
return state != State::Running;
}
bool CardDatabaseSetupPage::isSkippable() const
{
return state != State::Succeeded;
}
QString CardDatabaseSetupPage::stepTitle() const
{
return tr("Card Database");
}
QString CardDatabaseSetupPage::stepSubtitle() const
{
return tr("Cockatrice needs card data to know what you're playing with.");
}
void CardDatabaseSetupPage::retranslateUi()
{
retryButton->setText(tr("Retry"));
manualButton->setText(tr("Set up manually…"));
onToggleAdvanced(advancedToggleButton->isChecked());
urlLineEdit->setPlaceholderText(tr("Leave blank to use the default source"));
urlHintLabel->setText(tr("Only change this if you know you need a mirror or a custom card data source."));
restoreDefaultUrlButton->setText(tr("Restore default"));
applyAndRetryButton->setText(tr("Apply && retry"));
startupBehaviorLabel->setText(tr("Check for card database updates on startup"));
startupBehaviorCombo->setItemText(0, tr("Don't check"));
startupBehaviorCombo->setItemText(1, tr("Prompt for update"));
startupBehaviorCombo->setItemText(2, tr("Always update in the background"));
checkIntervalLabel->setText(tr("Check for card database updates every"));
checkIntervalSpinBox->setSuffix(tr(" days"));
setState(state);
}

View file

@ -0,0 +1,79 @@
#ifndef CARD_DATABASE_SETUP_PAGE_H
#define CARD_DATABASE_SETUP_PAGE_H
#include "../first_run_wizard_page.h"
#include <QSize>
class QComboBox;
class QLabel;
class QLineEdit;
class QProgressBar;
class QPushButton;
class QSpinBox;
class QWidget;
class CardDatabaseSetupPage : public FirstRunWizardPage
{
Q_OBJECT
public:
explicit CardDatabaseSetupPage(QWidget *parent = nullptr);
void initializePage() override;
bool isComplete() const override;
bool isSkippable() const override;
QString stepTitle() const override;
QString stepSubtitle() const override;
QString nextButtonText() const override;
bool handleNextClick() override;
void retranslateUi() override;
void onUpdateFinished(bool success);
signals:
void updateRequested();
void manualSetupRequested();
private:
enum class State
{
NotStarted,
Running,
Succeeded,
Failed,
};
void setState(State newState);
bool alreadyHaveDatabase() const;
QString oracleSettingsFilePath() const;
QString readCustomUrl() const;
void writeCustomUrl(const QString &url);
void onToggleAdvanced(bool open);
void onApplyCustomUrl();
void onRestoreDefaultUrl();
QLabel *statusLabel;
QProgressBar *progressBar;
QPushButton *retryButton;
QPushButton *manualButton;
QPushButton *advancedToggleButton;
QWidget *advancedPanel;
QLineEdit *urlLineEdit;
QLabel *urlHintLabel;
QPushButton *restoreDefaultUrlButton;
QPushButton *applyAndRetryButton;
QLabel *startupBehaviorLabel;
QComboBox *startupBehaviorCombo;
QLabel *checkIntervalLabel;
QSpinBox *checkIntervalSpinBox;
State state = State::NotStarted;
QSize windowSizeBeforeExpansion;
};
#endif // CARD_DATABASE_SETUP_PAGE_H

View file

@ -0,0 +1,30 @@
#include "finish_page.h"
#include <QLabel>
#include <QVBoxLayout>
FinishPage::FinishPage(QWidget *parent) : FirstRunWizardPage(parent)
{
bodyLabel = new QLabel(this);
bodyLabel->setWordWrap(true);
bodyLabel->setAlignment(Qt::AlignCenter);
auto *layout = new QVBoxLayout(this);
layout->addStretch();
layout->addWidget(bodyLabel);
layout->addStretch();
retranslateUi();
}
QString FinishPage::stepTitle() const
{
return tr("You're All Set");
}
void FinishPage::retranslateUi()
{
bodyLabel->setText(
tr("That's everything for now. Jump into Settings any time to change your mind about any of this.\n\n"
"Have fun!"));
}

View file

@ -0,0 +1,22 @@
#ifndef FINISH_PAGE_H
#define FINISH_PAGE_H
#include "../first_run_wizard_page.h"
class QLabel;
class FinishPage : public FirstRunWizardPage
{
Q_OBJECT
public:
explicit FinishPage(QWidget *parent = nullptr);
QString stepTitle() const override;
void retranslateUi() override;
private:
QLabel *bodyLabel;
};
#endif // FINISH_PAGE_H

View file

@ -0,0 +1,173 @@
#include "preferences_setup_page.h"
#include "../../client/settings/cache_settings.h"
#include "../../client/sound_engine.h"
#include "libcockatrice/settings/interface_settings.h"
#include "libcockatrice/settings/sound_settings.h"
#include "libcockatrice/settings/tabs_settings.h"
#include <QCheckBox>
#include <QComboBox>
#include <QFormLayout>
#include <QGroupBox>
#include <QLabel>
#include <QScrollArea>
#include <QVBoxLayout>
namespace
{
// The server destinations are omitted: during first run their tabs are not
// open yet, and the wizard offers no way to fill in the server/room details.
QList<StartupTab> wizardStartupTabOrder()
{
return {StartupTabHome, StartupTabVisualDeckStorage, StartupTabDeckStorage,
StartupTabReplays, StartupTabDeckEditor, StartupTabVisualDeckEditor};
}
} // namespace
PreferencesSetupPage::PreferencesSetupPage(QWidget *parent) : FirstRunWizardPage(parent)
{
auto *content = new QWidget;
auto *contentLayout = new QVBoxLayout(content);
gameplayGroup = new QGroupBox(content);
auto *gameplayLayout = new QVBoxLayout(gameplayGroup);
contentLayout->addWidget(gameplayGroup);
doubleClickToPlayCheckBox = new QCheckBox(gameplayGroup);
horizontalHandCheckBox = new QCheckBox(gameplayGroup);
playToStackCheckBox = new QCheckBox(gameplayGroup);
gameplayLayout->addWidget(doubleClickToPlayCheckBox);
gameplayLayout->addWidget(horizontalHandCheckBox);
gameplayLayout->addWidget(playToStackCheckBox);
notificationsGroup = new QGroupBox(content);
auto *notificationsLayout = new QVBoxLayout(notificationsGroup);
contentLayout->addWidget(notificationsGroup);
notificationsEnabledCheckBox = new QCheckBox(notificationsGroup);
soundEnabledCheckBox = new QCheckBox(notificationsGroup);
notificationsLayout->addWidget(notificationsEnabledCheckBox);
notificationsLayout->addWidget(soundEnabledCheckBox);
startupGroup = new QGroupBox(content);
auto *startupForm = new QFormLayout(startupGroup);
contentLayout->addWidget(startupGroup);
startupTabLabel = new QLabel(startupGroup);
startupTabSelector = new QComboBox(startupGroup);
startupTabSelector->setSizeAdjustPolicy(QComboBox::AdjustToContents);
for (StartupTab tab : wizardStartupTabOrder()) {
startupTabSelector->addItem(QString(), tab); // texts set in retranslateUi
}
startupForm->addRow(startupTabLabel, startupTabSelector);
contentLayout->addStretch();
auto *scrollArea = new QScrollArea(this);
scrollArea->setWidget(content);
scrollArea->setWidgetResizable(true);
scrollArea->setFrameShape(QFrame::NoFrame);
auto *layout = new QVBoxLayout(this);
layout->addWidget(scrollArea);
SettingsCache &settings = SettingsCache::instance();
connect(doubleClickToPlayCheckBox, &QCheckBox::toggled, &settings.userInterface(),
&InterfaceSettings::setDoubleClickToPlay);
connect(horizontalHandCheckBox, &QCheckBox::toggled, &settings.userInterface(),
&InterfaceSettings::setHorizontalHand);
connect(playToStackCheckBox, &QCheckBox::toggled, &settings.userInterface(), &InterfaceSettings::setPlayToStack);
connect(notificationsEnabledCheckBox, &QCheckBox::toggled, &settings.userInterface(),
&InterfaceSettings::setNotificationsEnabled);
connect(soundEnabledCheckBox, &QCheckBox::toggled, &settings.sound(), &SoundSettings::setSoundEnabled);
connect(soundEnabledCheckBox, &QCheckBox::toggled, soundEngine, &SoundEngine::testSound);
connect(startupTabSelector, QOverload<int>::of(&QComboBox::currentIndexChanged), this, [this](int index) {
if (index < 0) {
return;
}
SettingsCache::instance().tabs().setStartupTabIndex(startupTabSelector->itemData(index).toInt());
});
retranslateUi();
}
void PreferencesSetupPage::initializePage()
{
SettingsCache &settings = SettingsCache::instance();
doubleClickToPlayCheckBox->setChecked(settings.userInterface().getDoubleClickToPlay());
horizontalHandCheckBox->setChecked(settings.userInterface().getHorizontalHand());
playToStackCheckBox->setChecked(settings.userInterface().getPlayToStack());
notificationsEnabledCheckBox->setChecked(settings.userInterface().getNotificationsEnabled());
soundEnabledCheckBox->setChecked(settings.sound().getSoundEnabled());
startupTabSelector->setCurrentIndex(startupTabSelector->findData(settings.tabs().getStartupTabIndex()));
}
bool PreferencesSetupPage::isSkippable() const
{
return true;
}
QString PreferencesSetupPage::stepTitle() const
{
return tr("A Few Preferences");
}
QString PreferencesSetupPage::stepSubtitle() const
{
return tr("Defaults are fine — tweak these now or from Settings anytime.");
}
void PreferencesSetupPage::retranslateUi()
{
gameplayGroup->setTitle(tr("Gameplay"));
doubleClickToPlayCheckBox->setText(tr("Double-click cards to play them"));
doubleClickToPlayCheckBox->setToolTip(tr("When disabled, a single click plays the selected card onto the table."));
horizontalHandCheckBox->setText(tr("Display hand horizontally"));
horizontalHandCheckBox->setToolTip(
tr("Shows your hand as a row along the bottom of the table instead of a column beside it."));
playToStackCheckBox->setText(tr("Play all nonlands onto the stack by default"));
playToStackCheckBox->setToolTip(
tr("Cards you play appear on the stack so other players can respond to them, as in a tabletop game."));
notificationsGroup->setTitle(tr("Notifications && Sound"));
notificationsEnabledCheckBox->setText(tr("Show desktop notifications"));
soundEnabledCheckBox->setText(tr("Play sound effects"));
startupGroup->setTitle(tr("Startup"));
startupTabLabel->setText(tr("Startup tab:"));
const QList<StartupTab> tabs = wizardStartupTabOrder();
for (int i = 0; i < tabs.size(); ++i) {
QString name;
switch (tabs[i]) {
case StartupTabHome:
name = tr("Home");
break;
case StartupTabVisualDeckStorage:
name = tr("Visual Deck Storage");
break;
case StartupTabDeckStorage:
name = tr("Deck Storage");
break;
case StartupTabReplays:
name = tr("Game Replays");
break;
case StartupTabDeckEditor:
name = tr("Deck Editor");
break;
case StartupTabVisualDeckEditor:
name = tr("Visual Deck Editor");
break;
case StartupTabServer:
name = tr("Server");
break;
case StartupTabServerRoom:
name = tr("Server Room");
break;
}
startupTabSelector->setItemText(i, name);
}
}

View file

@ -0,0 +1,41 @@
#ifndef PREFERENCES_SETUP_PAGE_H
#define PREFERENCES_SETUP_PAGE_H
#include "../first_run_wizard_page.h"
class QCheckBox;
class QComboBox;
class QGroupBox;
class QLabel;
/** @brief A curated subset of settings for the user to adjust.
**/
class PreferencesSetupPage : public FirstRunWizardPage
{
Q_OBJECT
public:
explicit PreferencesSetupPage(QWidget *parent = nullptr);
void initializePage() override;
bool isSkippable() const override;
QString stepTitle() const override;
QString stepSubtitle() const override;
void retranslateUi() override;
private:
QGroupBox *gameplayGroup;
QCheckBox *doubleClickToPlayCheckBox;
QCheckBox *horizontalHandCheckBox;
QCheckBox *playToStackCheckBox;
QGroupBox *notificationsGroup;
QCheckBox *notificationsEnabledCheckBox;
QCheckBox *soundEnabledCheckBox;
QGroupBox *startupGroup;
QLabel *startupTabLabel;
QComboBox *startupTabSelector;
};
#endif // PREFERENCES_SETUP_PAGE_H

View file

@ -0,0 +1,231 @@
#include "theme_setup_page.h"
#include "../../client/settings/cache_settings.h"
#include "../../interface/palette_editor/palette_generator.h"
#include "../../interface/palette_editor/quick_setup_panel.h"
#include "../../interface/theme_manager.h"
#include "../../interface/widgets/general/background_sources.h"
#include "libcockatrice/settings/appearance_settings.h"
#include <QComboBox>
#include <QDir>
#include <QFile>
#include <QFormLayout>
#include <QGroupBox>
#include <QLabel>
#include <QMessageBox>
#include <QVBoxLayout>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent)
{
themeCombo = new QComboBox(this);
schemeCombo = new QComboBox(this);
schemeCombo->addItem(tr("Light"), QStringLiteral("Light"));
schemeCombo->addItem(tr("Dark"), QStringLiteral("Dark"));
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
schemeCombo->addItem(tr("Match system"), QStringLiteral("System"));
#endif
quickSetupPanel = new QuickSetupPanel(this);
connect(themeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onThemeChanged);
connect(schemeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onSchemeChanged);
connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &ThemeSetupPage::onGenerateFromAccent);
homeTabBackgroundCombo = new QComboBox(this);
for (const auto &entry : BackgroundSources::all()) {
homeTabBackgroundCombo->addItem(QObject::tr(entry.trKey), QVariant::fromValue(entry.type));
}
connect(homeTabBackgroundCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
&ThemeSetupPage::onHomeTabBackgroundChanged);
// Keep the scheme combo honest when the *theme* changes underneath it
// (switching theme reloads that theme's own stored colorScheme), and
// opportunistically seed a palette for themes that ship none at all.
// Mirrors AppearanceSettingsPage's identical listener for the combo-sync
// half of this.
connect(themeManager, &ThemeManager::themeChanged, this, [this] {
const QString newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName());
const ThemeConfig cfg = ThemeConfig::fromThemeDir(newDir);
const QString current = cfg.colorScheme;
schemeCombo->blockSignals(true);
const int idx = schemeCombo->findData(current);
schemeCombo->setCurrentIndex(idx >= 0 ? idx : 0);
schemeCombo->blockSignals(false);
maybeAutoGeneratePalette();
});
auto *form = new QFormLayout;
form->addRow(tr("Theme:"), themeCombo);
form->addRow(tr("Appearance:"), schemeCombo);
form->addRow(tr("Home screen background:"), homeTabBackgroundCombo);
accentGroup = new QGroupBox(this);
auto *accentLayout = new QVBoxLayout(accentGroup);
accentLayout->addWidget(quickSetupPanel);
auto *layout = new QVBoxLayout(this);
layout->addLayout(form);
layout->addWidget(accentGroup);
layout->addStretch();
retranslateUi();
}
void ThemeSetupPage::initializePage()
{
themeCombo->blockSignals(true);
themeCombo->clear();
const QString currentTheme = SettingsCache::instance().getThemeName();
for (const QString &name : themeManager->getAvailableThemes().keys()) {
themeCombo->addItem(name);
}
const int idx = themeCombo->findText(currentTheme);
themeCombo->setCurrentIndex(idx >= 0 ? idx : 0);
themeCombo->blockSignals(false);
const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName());
const ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath);
schemeCombo->blockSignals(true);
const int schemeIdx = schemeCombo->findData(cfg.colorScheme);
schemeCombo->setCurrentIndex(schemeIdx >= 0 ? schemeIdx : 0);
schemeCombo->blockSignals(false);
homeTabBackgroundCombo->blockSignals(true);
QString homeTabSource = SettingsCache::instance().appearance().getHomeTabBackgroundSource();
int homeTabIdx = homeTabBackgroundCombo->findData(BackgroundSources::fromId(homeTabSource));
homeTabBackgroundCombo->setCurrentIndex(homeTabIdx >= 0 ? homeTabIdx : 0);
homeTabBackgroundCombo->blockSignals(false);
// Opening the page must not touch the running application's palette:
// previews and auto-generation only happen in response to the user
// actually changing a control, never on mere page visibility.
paletteDirty = false;
}
QString ThemeSetupPage::currentScheme() const
{
return schemeCombo->currentData().toString();
}
QString ThemeSetupPage::resolvedScheme() const
{
const QString scheme = currentScheme();
if (scheme.isEmpty() || scheme == QStringLiteral("System")) {
return themeManager->isDarkMode(themeManager->getCurrentThemePath()) ? "Dark" : "Light";
}
return scheme;
}
void ThemeSetupPage::onThemeChanged(int index)
{
if (index < 0) {
return;
}
paletteDirty = false;
SettingsCache::instance().setThemeName(themeCombo->itemText(index));
// Scheme-combo sync and auto-generation both happen via the
// ThemeManager::themeChanged listener above, triggered by setThemeName.
}
void ThemeSetupPage::onSchemeChanged()
{
themeManager->setColorScheme(currentScheme());
}
void ThemeSetupPage::onHomeTabBackgroundChanged(int index)
{
if (index < 0) {
return;
}
auto type = homeTabBackgroundCombo->currentData().value<BackgroundSources::Type>();
SettingsCache::instance().appearance().setHomeTabBackgroundSource(BackgroundSources::toId(type));
}
void ThemeSetupPage::onGenerateFromAccent(const QColor &accent, int intensity)
{
PaletteConfig cfg = PaletteGenerator::fromAccent(accent, intensity, resolvedScheme());
themeManager->previewPalette(cfg, resolvedScheme());
paletteDirty = true;
}
void ThemeSetupPage::maybeAutoGeneratePalette()
{
const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName());
const QString scheme = resolvedScheme();
if (PaletteConfig::fromScheme(dirPath, scheme).hasPalette() ||
PaletteConfig::fromDefault(dirPath, scheme).hasPalette()) {
return; // theme already has something real to show -- leave it alone
}
// The theme+scheme combination has nothing saved and nothing shipped, and
// the user just switched to it. Rather than leaving a flat, unstyled look,
// seed one from whatever accent QuickSetupPanel currently holds and mark
// it dirty so it's written to disk if the user moves on. Only ever reached
// through user interaction (theme/scheme change, accent drag) -- never on
// page open.
PaletteConfig generated =
PaletteGenerator::fromAccent(quickSetupPanel->accentColor(), quickSetupPanel->intensity(), scheme);
themeManager->previewPalette(generated, scheme);
paletteDirty = true;
}
bool ThemeSetupPage::validatePage()
{
if (paletteDirty) {
const QString scheme = resolvedScheme();
PaletteConfig cfg =
PaletteGenerator::fromAccent(quickSetupPanel->accentColor(), quickSetupPanel->intensity(), scheme);
if (!ThemeManager::commitPalette(writableThemeDir(), scheme, cfg)) {
QMessageBox::warning(this, tr("Save failed"),
tr("Could not write the theme palette to:\n%1").arg(writableThemeDir()));
return false;
}
themeManager->reloadCurrentTheme();
}
return true;
}
QString ThemeSetupPage::writableThemeDir() const
{
// Built-in themes resolve to the read-only system themes directory;
// palette edits must go to the user themes directory instead, exactly
// as PaletteEditorDialog does.
const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName());
if (!dirPath.isEmpty()) {
const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test");
QFile f(probe);
if (f.open(QIODevice::WriteOnly)) {
f.close();
f.remove();
return dirPath;
}
}
return QDir(SettingsCache::instance().paths().getThemesPath())
.absoluteFilePath(SettingsCache::instance().getThemeName());
}
bool ThemeSetupPage::isSkippable() const
{
return true;
}
QString ThemeSetupPage::stepTitle() const
{
return tr("Pick a Look");
}
QString ThemeSetupPage::stepSubtitle() const
{
return tr("You can fine-tune every colour later from Settings → Appearance.");
}
void ThemeSetupPage::retranslateUi()
{
accentGroup->setTitle(tr("Accent colour (optional)"));
}

View file

@ -0,0 +1,58 @@
#ifndef THEME_SETUP_PAGE_H
#define THEME_SETUP_PAGE_H
#include "../first_run_wizard_page.h"
class QComboBox;
class QGroupBox;
class QuickSetupPanel;
/** @brief First-run theme step. Reuses the same building blocks as Appearance
* settings and the Palette Editor (ThemeManager, PaletteConfig,
* PaletteGenerator, and the QuickSetupPanel widget itself) rather than
* reimplementing palette generation or preview here.
*
* Behavior specific to this page (deliberately not pushed down into
* ThemeManager, to avoid changing app-wide behaviour for existing installs):
* - Opening the page never changes the running palette; previews and
* auto-generation only happen when the user actually changes a control.
* - If a theme+scheme the user selects has no saved palette and no shipped
* default, one is generated from the QuickSetupPanel's current accent so
* the preview doesn't fall back to a flat, unstyled look. */
class ThemeSetupPage : public FirstRunWizardPage
{
Q_OBJECT
public:
explicit ThemeSetupPage(QWidget *parent = nullptr);
void initializePage() override;
bool validatePage() override;
bool isSkippable() const override;
QString stepTitle() const override;
QString stepSubtitle() const override;
void retranslateUi() override;
private slots:
void onThemeChanged(int index);
void onSchemeChanged();
void onGenerateFromAccent(const QColor &accent, int intensity);
void onHomeTabBackgroundChanged(int index);
private:
QString currentScheme() const;
QString resolvedScheme() const; // "System" -> actual Light/Dark
void maybeAutoGeneratePalette();
QString writableThemeDir() const;
QComboBox *themeCombo;
QComboBox *schemeCombo;
QGroupBox *accentGroup;
QuickSetupPanel *quickSetupPanel;
QComboBox *homeTabBackgroundCombo;
bool paletteDirty = false;
};
#endif // THEME_SETUP_PAGE_H

View file

@ -0,0 +1,79 @@
#include "welcome_page.h"
#include "../../../../main.h"
#include "../../client/settings/cache_settings.h"
#include "../../settings_page/general_settings_page.h"
#include "libcockatrice/settings/personal_settings.h"
#include <QApplication>
#include <QComboBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QLocale>
#include <QTranslator>
#include <QVBoxLayout>
WelcomePage::WelcomePage(QWidget *parent) : FirstRunWizardPage(parent)
{
bodyLabel = new QLabel(this);
bodyLabel->setWordWrap(true);
bodyLabel->setAlignment(Qt::AlignCenter);
languageLabel = new QLabel(this);
langCombo = new QComboBox(this);
for (const QString &code : GeneralSettingsPage::findQmFiles()) {
langCombo->addItem(GeneralSettingsPage::languageName(code), code);
}
QString current = SettingsCache::instance().personal().getLang();
if (current.isEmpty()) {
current = QLocale::system().name();
}
int index = langCombo->findData(current);
if (index < 0) {
index = langCombo->findData(current.section('_', 0, 0));
}
if (index >= 0) {
langCombo->setCurrentIndex(index);
}
connect(langCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &WelcomePage::languageChanged);
auto *languageRow = new QHBoxLayout;
languageRow->addStretch();
languageRow->addWidget(languageLabel);
languageRow->addWidget(langCombo);
languageRow->addStretch();
auto *layout = new QVBoxLayout(this);
layout->addStretch();
layout->addWidget(bodyLabel);
layout->addStretch();
layout->addLayout(languageRow);
retranslateUi();
}
void WelcomePage::languageChanged(int index)
{
if (index < 0) {
return;
}
SettingsCache::instance().personal().setLang(langCombo->itemData(index).toString());
qApp->removeTranslator(translator); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast)
installNewTranslator();
}
QString WelcomePage::stepTitle() const
{
return tr("Welcome!");
}
void WelcomePage::retranslateUi()
{
bodyLabel->setText(tr("Let's get you set up. This will only take a minute — "
"we'll grab the card database, pick a look you like, "
"and get you ready to connect to a server.\n\n"
"You can change any of this later from Settings."));
languageLabel->setText(tr("Language:"));
}

View file

@ -0,0 +1,28 @@
#ifndef WELCOME_PAGE_H
#define WELCOME_PAGE_H
#include "../first_run_wizard_page.h"
class QComboBox;
class QLabel;
class WelcomePage : public FirstRunWizardPage
{
Q_OBJECT
public:
explicit WelcomePage(QWidget *parent = nullptr);
QString stepTitle() const override;
void retranslateUi() override;
private slots:
void languageChanged(int index);
private:
QLabel *bodyLabel;
QLabel *languageLabel;
QComboBox *langCombo;
};
#endif // WELCOME_PAGE_H

View file

@ -0,0 +1,62 @@
import QtQuick
Item {
id: root
ShaderEffect {
id: effectA
anchors.fill: parent
opacity: bannerConfig.frontIsA ? 1.0 : 0.0
Behavior on opacity { NumberAnimation { duration: 450; easing.type: Easing.InOutCubic } }
property real iTime: bannerConfig.time
property real uAspect: bannerConfig.aspect
property real uMode: bannerConfig.modeA
property real uSpeed: bannerConfig.speedA
property real uSeed: bannerConfig.seedA
property vector4d uColorA: Qt.vector4d(bannerConfig.colorA.r, bannerConfig.colorA.g, bannerConfig.colorA.b, 1.0)
property vector4d uColorB: Qt.vector4d(bannerConfig.colorB.r, bannerConfig.colorB.g, bannerConfig.colorB.b, 1.0)
property vector4d uAccent: Qt.vector4d(bannerConfig.accent.r, bannerConfig.accent.g, bannerConfig.accent.b, 1.0)
property real uLogoGlow: bannerConfig.logoGlow
fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb"
}
ShaderEffect {
id: effectB
anchors.fill: parent
opacity: bannerConfig.frontIsA ? 0.0 : 1.0
Behavior on opacity { NumberAnimation { duration: 450; easing.type: Easing.InOutCubic } }
property real iTime: bannerConfig.time
property real uAspect: bannerConfig.aspect
property real uMode: bannerConfig.modeB
property real uSpeed: bannerConfig.speedB
property real uSeed: bannerConfig.seedB
property vector4d uColorA: Qt.vector4d(bannerConfig.colorA.r, bannerConfig.colorA.g, bannerConfig.colorA.b, 1.0)
property vector4d uColorB: Qt.vector4d(bannerConfig.colorB.r, bannerConfig.colorB.g, bannerConfig.colorB.b, 1.0)
property vector4d uAccent: Qt.vector4d(bannerConfig.accent.r, bannerConfig.accent.g, bannerConfig.accent.b, 1.0)
property real uLogoGlow: bannerConfig.logoGlow
fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb"
}
// The hero logo itself — breathes cleanly over a 0.5–1.0 opacity range
Image {
id: logo
anchors.centerIn: parent
visible: bannerConfig.logoVisible
source: "qrc:/resources/cockatrice-logo-white.svg"
width: root.height * 0.6
height: width * (sourceSize.height > 0 ? sourceSize.height / Math.max(sourceSize.width, 1) : 1)
fillMode: Image.PreserveAspectFit
smooth: true
opacity: 0.5 + 0.5 * bannerConfig.logoGlow
sourceSize: Qt.size(256, 256)
Behavior on opacity { NumberAnimation { duration: 300; easing.type: Easing.InOutSine } }
transform: Scale {
origin.x: logo.width / 2
origin.y: logo.height / 2
xScale: 0.94 + 0.06 * bannerConfig.logoGlow
yScale: 0.94 + 0.06 * bannerConfig.logoGlow
}
}
}

View file

@ -0,0 +1,195 @@
#include "shader_banner_widget.h"
#include "banner_shader_config.h"
#include <QPainter>
#include <QQmlContext>
#include <QQmlEngine>
#include <QQuickWidget>
#include <QResizeEvent>
#include <QStackedLayout>
namespace
{
// Near-black base palette -- the background is dark and quiet so the green
// accent stands out.
constexpr QRgb kColorA = 0x1A1A20;
constexpr QRgb kColorB = 0x0E0E12;
constexpr QRgb kAccent = 0x8BDD6B;
} // namespace
class GradientFallbackWidget : public QWidget
{
public:
using QWidget::QWidget;
protected:
void paintEvent(QPaintEvent *) override
{
QPainter painter(this);
QLinearGradient gradient(0, 0, width(), height());
gradient.setColorAt(0.0, QColor(kColorA));
gradient.setColorAt(1.0, QColor(kColorB));
painter.fillRect(rect(), gradient);
}
};
BannerHost::BannerHost(QWidget *parent) : QWidget(parent)
{
setFixedHeight(150);
stack = new QStackedLayout(this);
stack->setContentsMargins(0, 0, 0, 0);
fallback = new GradientFallbackWidget(this);
stack->addWidget(fallback);
quickWidget = new QQuickWidget(this);
quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView);
config = new BannerShaderConfig(quickWidget->engine());
quickWidget->rootContext()->setContextProperty("bannerConfig", config);
quickWidget->setSource(QUrl("qrc:/onboarding/qml/BrandBanner.qml"));
if (quickWidget->status() == QQuickWidget::Error) {
activateFallback();
} else {
connect(quickWidget, &QQuickWidget::sceneGraphError, this, &BannerHost::onSceneGraphFailed);
stack->addWidget(quickWidget);
stack->setCurrentWidget(quickWidget);
}
connect(&clock, &QTimer::timeout, this, &BannerHost::tick);
clock.setInterval(16); // ~60fps; the shader itself is cheap, this is just a wall clock
applyMotifPreset(currentMotif);
updateAspect();
}
void BannerHost::activateFallback()
{
if (usingFallback) {
return;
}
usingFallback = true;
clock.stop();
stack->setCurrentWidget(fallback);
if (quickWidget) {
quickWidget->deleteLater(); // takes BannerShaderConfig (parented to its engine) with it
quickWidget = nullptr;
config = nullptr;
}
}
void BannerHost::onSceneGraphFailed()
{
activateFallback();
}
void BannerHost::setMotif(Motif motif)
{
currentMotif = motif;
applyMotifPreset(motif);
}
BannerHost::Preset BannerHost::presetFor(Motif motif)
{
// speed/seed tuned per motif so e.g. the network "pulse" (Account) reads
// at a deliberately calmer cadence than the data "scan" lines
// (Preferences), even though both come from the same shader.
switch (motif) {
case Motif::Welcome:
return {0.0, 0.6, 0.15};
case Motif::CardDatabase:
return {1.0, 1.3, 0.42};
case Motif::Theming:
return {2.0, 1.2, 0.73};
case Motif::Account:
return {3.0, 0.8, 0.28};
case Motif::Preferences:
return {4.0, 1.0, 0.61};
case Motif::Finish:
return {5.0, 1.0, 0.91};
}
return {0.0, 0.6, 0.15};
}
void BannerHost::applyMotifPreset(Motif motif)
{
if (usingFallback || !config) {
return;
}
const Preset p = presetFor(motif);
config->setColorA(QColor(kColorA));
config->setColorB(QColor(kColorB));
config->setAccent(QColor(kAccent));
config->setLogoVisible(motif == Motif::Welcome);
if (isFirstApply) {
// Nothing on screen yet -- write straight into the front bank, no
// crossfade needed for the very first paint.
config->setModeA(p.mode);
config->setSpeedA(p.speed);
config->setSeedA(p.seed);
config->setFrontIsA(true);
isFirstApply = false;
return;
}
// Write the new preset into whichever bank is currently hidden, then
// flip which one is front. QML's opacity Behavior does the actual
// crossfade -- BannerHost never animates anything itself.
if (config->frontIsA()) {
config->setModeB(p.mode);
config->setSpeedB(p.speed);
config->setSeedB(p.seed);
config->setFrontIsA(false);
} else {
config->setModeA(p.mode);
config->setSpeedA(p.speed);
config->setSeedA(p.seed);
config->setFrontIsA(true);
}
}
void BannerHost::updateAspect()
{
if (config && height() > 0) {
config->setAspect(qreal(width()) / qreal(height()));
}
}
void BannerHost::resizeEvent(QResizeEvent *event)
{
QWidget::resizeEvent(event);
updateAspect();
}
void BannerHost::showEvent(QShowEvent *event)
{
QWidget::showEvent(event);
if (!usingFallback) {
elapsed.restart();
clock.start();
}
}
void BannerHost::hideEvent(QHideEvent *event)
{
QWidget::hideEvent(event);
clock.stop();
}
void BannerHost::tick()
{
if (config) {
qreal t = elapsed.elapsed() / 1000.0;
config->setTime(t);
// Visible breathing for the logo: oscillates between 0.0 and 1.0
qreal glow = 0.5 + 0.5 * qSin(t * 0.4);
config->setLogoGlow(glow);
}
}

View file

@ -0,0 +1,83 @@
#ifndef SHADER_BANNER_WIDGET_H
#define SHADER_BANNER_WIDGET_H
#include <QElapsedTimer>
#include <QTimer>
#include <QWidget>
class BannerShaderConfig;
class QQuickWidget;
class GradientFallbackWidget;
class QStackedLayout;
/** @brief Onboarding banner: a subtle, looping brand-shader animation, one of six
* per-page "motifs" driving the same prebaked fragment shader
* (onboarding/shaders/brand_banner.frag) with different uniform values, so
* every page feels distinct but unmistakably part of the same family.
*
* Motif switches crossfade smoothly (see BrandBanner.qml's two stacked
* ShaderEffect layers + Behavior on opacity) rather than cutting instantly
* -- BannerHost just writes the new preset into whichever layer is
* currently hidden and flips BannerShaderConfig::frontIsA; QML handles the
* actual animation declaratively.
*
* Falls back to a static two-stop gradient (no shader, no QQuickWidget) if
* the platform's Qt Quick scenegraph can't initialize -- e.g. software
* rendering only, or a CI/VM environment with no GPU -- so onboarding
* never blocks or blanks out over a graphics driver problem. The fallback
* is permanent for the lifetime of this widget once triggered. */
class BannerHost : public QWidget
{
Q_OBJECT
public:
enum class Motif
{
Welcome,
CardDatabase,
Theming,
Account,
Preferences,
Finish,
};
explicit BannerHost(QWidget *parent = nullptr);
void setMotif(Motif motif);
protected:
void showEvent(QShowEvent *event) override;
void hideEvent(QHideEvent *event) override;
void resizeEvent(QResizeEvent *event) override;
private slots:
void tick();
void onSceneGraphFailed();
private:
struct Preset
{
qreal mode;
qreal speed;
qreal seed;
};
static Preset presetFor(Motif motif);
void applyMotifPreset(Motif motif);
void updateAspect();
void activateFallback();
QStackedLayout *stack;
QQuickWidget *quickWidget = nullptr;
BannerShaderConfig *config = nullptr;
GradientFallbackWidget *fallback = nullptr;
QTimer clock;
QElapsedTimer elapsed;
Motif currentMotif = Motif::Welcome;
bool usingFallback = false;
bool isFirstApply = true;
};
#endif // SHADER_BANNER_WIDGET_H

View file

@ -0,0 +1,461 @@
#version 440
// ════════════════════════════════════════════════════════════════════════
// brand_banner.frag
//
// One shader, six motifs (uMode 0..5). All motifs composite over a shared
// backgroundField() whose colour is flow-noise-modulated blend of uColorA
// and uColorB. SDFs operate in aspect-corrected space (ac.x = uv.x *
// uAspect) to preserve shape proportions on the wide banner.
//
// IMPORTANT: the uniform block below must list custom uniforms in EXACTLY
// the order they're declared as properties on each ShaderEffect instance in
// BrandBanner.qml (after the two Qt-supplied members, qt_Matrix/qt_Opacity).
// ════════════════════════════════════════════════════════════════════════
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf
{
mat4 qt_Matrix;
float qt_Opacity;
float iTime;
float uAspect;
float uMode;
float uSpeed;
float uSeed;
vec4 uColorA;
vec4 uColorB;
vec4 uAccent;
float uLogoGlow;
};
// ── Primitives ──────────────────────────────────────────────────────────
float hash21(vec2 p)
{
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float valueNoise(vec2 p)
{
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float fbm(vec2 p)
{
float v = 0.0;
float amp = 0.5;
for (int i = 0; i < 3; i++) {
v += amp * valueNoise(p);
p *= 2.03;
amp *= 0.5;
}
return v;
}
float flowNoise(vec2 p, float t)
{
vec2 warp1 = vec2(fbm(p + vec2(0.0, 0.0)), fbm(p + vec2(5.2, 1.3)));
vec2 warp2 = vec2(fbm(p + 4.0 * warp1 + vec2(1.7, 9.2) + t * 0.6),
fbm(p + 4.0 * warp1 + vec2(8.3, 2.8) - t * 0.5));
return fbm(p + 4.0 * warp2 + t * 0.15);
}
float bloom(float d, float coreRadius, float haloRadius)
{
float core = exp(-(d * d) / (coreRadius * coreRadius));
float halo = exp(-d / haloRadius) * 0.35;
return core + halo;
}
float roundedBoxSDF(vec2 p, vec2 halfSize, float radius)
{
vec2 d = abs(p) - halfSize + radius;
return length(max(d, 0.0)) - radius + min(max(d.x, d.y), 0.0);
}
// Rotated box SDF -- applies 2D rotation to p before evaluating roundedBoxSDF.
float rotatedBoxSDF(vec2 p, vec2 halfSize, float radius, float angle)
{
float c = cos(angle);
float s = sin(angle);
vec2 rp = vec2(p.x * c - p.y * s, p.x * s + p.y * c);
return roundedBoxSDF(rp, halfSize, radius);
}
float vignette(vec2 uv)
{
vec2 c = uv - 0.5;
c.x *= max(uAspect, 0.0001);
return smoothstep(1.0, 0.25, length(c));
}
// ── Shared background ───────────────────────────────────────────────────
vec3 backgroundField(vec2 uv, float time)
{
// Diagonal luminance gradient from (0,0) to (1,1) used as blend factor
// between uColorA and uColorB; modulated by flowNoise.
float baseD = smoothstep(0.0, 1.0, uv.y * 0.5 + uv.x * 0.2);
float painted = flowNoise(uv * 1.5, time * 0.04) - 0.5;
baseD = clamp(baseD + painted * 0.12, 0.0, 1.0);
vec3 col = mix(uColorA.rgb, uColorB.rgb, baseD);
// Low-frequency fBM noise pushes local colour toward uColorB for depth
float deep = fbm(uv * 1.0 + vec2(37.1, 12.4) + time * 0.015);
col = mix(col, uColorB.rgb, (deep - 0.5) * 0.08);
// Accent-coloured fog layer: flowNoise peaks above 0.6 contribute accent
float fog = flowNoise(uv * 0.8 + vec2(100.0, 50.0), time * 0.02);
col += uAccent.rgb * max(fog - 0.6, 0.0) * 0.10;
return col;
}
// ── Motifs ──────────────────────────────────────────────────────────────
// Centre bloom, flow-noise shimmer gated to centre, and 48 orbiting ember
// particles that deflect into a tight ring near the centre.
vec3 motifWelcome(vec2 uv, vec3 bg, float t)
{
vec3 col = bg;
float asp = max(uAspect, 0.001);
vec2 ac = vec2(uv.x * asp, uv.y);
vec2 center = vec2(asp * 0.5, 0.5);
float cDist = length(ac - center);
// Centre bloom at logo position; intensity scales with uLogoGlow
float centreLight = bloom(cDist, 0.08 * asp, 0.40 * asp);
col += centreLight * 0.20 * uLogoGlow;
// Flow-noise shimmer gated by Gaussian mask at centre
float shimmer = flowNoise(ac * 0.8 + vec2(55.0, 33.0), t * 0.05) * 0.5 + 0.5;
float shimmerMask = exp(-(cDist * cDist) / (0.18 * asp * 0.18 * asp));
col += shimmer * shimmerMask * 0.04 * uLogoGlow;
// 48 ember particles: hash-seeded position, speed, size, brightness.
// Embers within a distance threshold of centre are deflected into an
// orbital ring via tangent displacement perpendicular to the centre vector.
const int EMBERS = 48;
for (int i = 0; i < EMBERS; i++) {
float fi = float(i);
float baseX = hash21(vec2(fi * 7.31 + uSeed, fi * 3.17));
float baseY = hash21(vec2(fi * 11.9 + uSeed * 1.4, fi * 5.53));
float riseSpeed = 0.025 + hash21(vec2(fi * 1.7, uSeed * 2.1)) * 0.035;
float driftAmp = 0.04 + hash21(vec2(fi * 9.3, uSeed)) * 0.06;
float driftFreq = 0.3 + hash21(vec2(fi * 4.1, uSeed * 3.3)) * 0.5;
float pX = baseX * asp + sin(t * driftFreq + fi * 1.7) * driftAmp * asp;
float pY = fract(baseY + t * riseSpeed);
float size = 0.006 + hash21(vec2(fi * 2.9, uSeed * 4.7)) * 0.012;
float bright = 0.15 + hash21(vec2(fi * 6.1, uSeed * 0.9)) * 0.30;
// Fade out near top/bottom edges
float edgeFade = smoothstep(0.0, 0.12, pY) * smoothstep(1.0, 0.88, pY);
float twinkle = 0.6 + 0.4 * sin(t * (1.2 + fi * 0.37) + fi * 2.9);
vec2 ePos = vec2(pX, pY);
// Embers near centre: deflect into orbital ring via tangent displacement
vec2 toCenter = ePos - center;
float distToCenter = length(toCenter);
float ringWeight = smoothstep(0.38 * asp, 0.06 * asp, distToCenter);
float orbitPhase = t * (0.15 + fi * 0.020) + fi * 2.3;
float orbitAmount = 0.020 + hash21(vec2(fi * 12.3, uSeed * 2.7)) * 0.020;
vec2 tangent = vec2(-toCenter.y, toCenter.x);
vec2 deflected = ePos + tangent * ringWeight * orbitAmount * asp * sin(orbitPhase);
float pushOut = ringWeight * (0.008 + hash21(vec2(fi * 6.7, uSeed * 1.1)) * 0.012) * asp;
deflected += normalize(toCenter + 0.001) * pushOut;
float dist = length(ac - deflected);
float intensity = bright * edgeFade * twinkle;
col += uAccent.rgb * bloom(dist, size, size * 4.0) * intensity;
}
return col;
}
// 25 card-shaped box SDFs at parallax depths drifting horizontally across
// the banner; each card has a semi-transparent fill, accent outline, and
// card-back diamond pattern.
vec3 motifCardDatabase(vec2 uv, vec3 bg, float t)
{
vec3 col = bg;
float asp = max(uAspect, 0.001);
vec2 ac = vec2(uv.x * asp, uv.y);
const int CARDS = 25;
for (int i = 0; i < CARDS; i++) {
float fi = float(i);
// Parallax depth via hash; used to scale size, speed, brightness
float depth = hash21(vec2(fi * 1.37 + uSeed, fi * 0.91));
// Card dimensions in corrected space (portrait: height > width)
float cardH = mix(0.055, 0.15, depth);
cardH *= 0.85 + 0.30 * hash21(vec2(fi * 3.14, uSeed * 2.71));
float cardW = cardH * 0.71; // 5:7 ratio
// Horizontal drift; nearer cards (higher depth) move faster
float speed = mix(0.06, 0.18, depth);
float xPhase = hash21(vec2(fi * 7.13, uSeed * 4.37));
xPhase = fract(xPhase + t * speed);
float x = mix(-1.5, asp + 1.5, xPhase);
// Vertical position: hash distribution with sinusoidal oscillation
float yBase = hash21(vec2(fi * 2.91, uSeed * 1.63));
float y = yBase + sin(t * 0.6 + fi * 1.9) * 0.035;
y = clamp(y, cardH + 0.02, 1.0 - cardH - 0.02);
// Random rotation angle ±4 degrees
float tilt = (hash21(vec2(fi * 5.71, uSeed * 8.29)) - 0.5) * 0.14;
vec2 p = ac - vec2(x, y);
float d = rotatedBoxSDF(p, vec2(cardW, cardH), cardW * 0.14, tilt);
// Semi-transparent dark fill
float fill = smoothstep(0.015, -0.005, d);
col = mix(col, uColorB.rgb * 0.55, fill * 0.50);
// Accent outline
float edge = smoothstep(0.035, 0.0, abs(d));
col += uAccent.rgb * edge * mix(0.18, 0.50, 1.0 - depth);
// Card-back diamond: smaller rotated box inset from card edges
float innerD = rotatedBoxSDF(p, vec2(cardW * 0.45, cardH * 0.55), cardW * 0.08, tilt);
float innerEdge = smoothstep(0.012, 0.0, abs(innerD));
col += uAccent.rgb * innerEdge * fill * 0.12 * (1.0 - depth);
// Centre dot
float dotDist = length(p);
col += uAccent.rgb * bloom(dotDist, 0.008, 0.02) * fill * 0.15 * (1.0 - depth);
}
return col;
}
// 4 horizontal bands with multi-frequency sinusoidal warp and pulsing width.
vec3 motifTheming(vec2 uv, vec3 bg, float t)
{
vec3 col = bg;
const int BANDS = 4;
for (int i = 0; i < BANDS; i++) {
float fi = float(i);
float yCenter = 0.18 + fi * 0.22;
// Three summed sinusoids for horizontal undulation
float wave = sin(uv.x * 3.2 + t * 0.5 + fi * 2.1) * 0.08;
wave += sin(uv.x * 7.0 - t * 0.3 + fi * 1.3) * 0.035;
wave += sin(uv.x * 1.6 + t * 0.18 + fi * 3.7) * 0.05;
float bandDist = abs(uv.y - yCenter - wave);
float bandWidth = 0.04 + sin(t * 0.2 + fi * 0.8) * 0.012;
float band = smoothstep(bandWidth, 0.0, bandDist);
// Upper bands have higher intensity
float intensity = mix(0.15, 0.38, 1.0 - fi / float(BANDS));
col += uAccent.rgb * band * intensity;
}
return col;
}
// 14 nodes at pseudo-random positions with sinusoidal pulse; edges drawn
// between nodes within a threshold distance; central glow + periodic ring.
vec3 motifAccount(vec2 uv, vec3 bg, float t)
{
vec3 col = bg;
float asp = max(uAspect, 0.001);
vec2 ac = vec2(uv.x * asp, uv.y);
vec2 center = vec2(asp * 0.5, 0.5);
const int NODES = 14;
vec2 nodePos[14];
float nodePulse[14];
for (int i = 0; i < NODES; i++) {
float fi = float(i);
// Hash-seeded position with gentle sinusoidal drift
float nx = hash21(vec2(fi * 3.17 + uSeed, fi * 1.93)) * asp;
float ny = hash21(vec2(fi * 5.41 + uSeed * 1.7, fi * 2.79));
float dx = sin(t * 0.12 + fi * 1.7) * 0.08;
float dy = cos(t * 0.09 + fi * 2.3) * 0.04;
vec2 pos = vec2(nx + dx, ny + dy);
nodePos[i] = pos;
// Per-node pulse phase, normalised to [0, 1]
float pulsePhase = hash21(vec2(fi * 4.31, uSeed * 6.17));
float pulse = sin(t * 0.8 + pulsePhase * 6.283) * 0.5 + 0.5;
nodePulse[i] = pulse;
// Node glow via bloom; intensity modulated by pulse
float dist = length(ac - pos);
col += uAccent.rgb * bloom(dist, 0.018, 0.08) * mix(0.20, 0.45, pulse);
}
// Edges: connect nodes within a radius threshold
float connectDist = asp * 0.22;
for (int i = 0; i < NODES; i++) {
for (int j = i + 1; j < NODES; j++) {
float pairDist = length(nodePos[i] - nodePos[j]);
if (pairDist < connectDist) {
float strength = 1.0 - pairDist / connectDist;
vec2 pa = ac - nodePos[i];
vec2 ba = nodePos[j] - nodePos[i];
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
float lineDist = length(pa - ba * h);
col += uAccent.rgb * smoothstep(0.010, 0.0, lineDist) * strength * 0.10;
}
}
}
// Central bloom at banner centre
float cDist = length(ac - center);
col += uAccent.rgb * bloom(cDist, 0.04, 0.25) * 0.12;
// Periodic expanding ring from centre
float ripplePhase = t * 0.4;
float rippleDist = abs(cDist - fract(ripplePhase) * asp * 0.7);
col += uAccent.rgb * smoothstep(0.02, 0.0, rippleDist) * 0.10;
return col;
}
// 18x5 toggle-grid of rounded boxes with hash-driven on/off per cell;
// a scanning highlight sweeps L-to-R, brightening cells near the scan line.
vec3 motifPreferences(vec2 uv, vec3 bg, float t)
{
vec3 col = bg;
float cols = 18.0;
float rows = 5.0;
vec2 gridUV = uv * vec2(cols, rows);
vec2 cell = fract(gridUV) - 0.5;
vec2 cellId = floor(gridUV);
// On/off state per cell, hash-seeded for pseudo-randomness
float on = step(0.55, hash21(cellId + uSeed * 10.0));
float d = roundedBoxSDF(cell, vec2(0.28, 0.32), 0.06);
// Filled "on" cells
float cellFill = smoothstep(0.04, -0.02, d);
col += uAccent.rgb * cellFill * on * 0.18;
// Cell borders (drawn on all cells)
float border = smoothstep(0.025, 0.0, abs(d));
col += uAccent.rgb * border * 0.06;
// Scanning highlight: thin line + soft glow sweeping L-to-R
float scanX = fract(t * 0.15);
float scanDist = abs(uv.x - scanX);
float scanLine = smoothstep(0.015, 0.0, scanDist);
col += uAccent.rgb * scanLine * 0.40;
float scanGlow = smoothstep(0.08, 0.0, scanDist);
col += uAccent.rgb * scanGlow * 0.08;
// "On" cells near the scan line get extra brightness
float scanProximity = smoothstep(0.12, 0.0, scanDist);
col += uAccent.rgb * cellFill * on * scanProximity * 0.15;
return col;
}
// Centre radial bloom with sinusoidal pulse, 4 expanding ring halos with
// outer glow falloff, and 35 rising particles.
vec3 motifFinish(vec2 uv, vec3 bg, float t)
{
vec3 col = bg;
float asp = max(uAspect, 0.001);
vec2 ac = vec2(uv.x * asp, uv.y);
vec2 center = vec2(asp * 0.5, 0.5);
float cDist = length(ac - center);
// Centre bloom with sinusoidal pulse modulation
float pulse = 0.65 + 0.35 * sin(t * 0.4);
col += uAccent.rgb * bloom(cDist, 0.12, 0.55) * 0.10 * pulse;
// 4 expanding rings: radius increases via phase; ring width grows with
// expansion; combined with exponential outer glow falloff
for (int i = 0; i < 4; i++) {
float fi = float(i);
float phase = fract(t * 0.06 + fi * 0.25);
float ringRadius = phase * asp * 0.7;
float ringDist = abs(cDist - ringRadius);
float ringWidth = 0.025 + phase * 0.025;
float ring = smoothstep(ringWidth, 0.0, ringDist);
float outerGlow = exp(-ringDist / (0.03 + phase * 0.02)) * 0.3;
float combined = ring + outerGlow;
float fade = 1.0 - phase * 0.5;
col += uAccent.rgb * combined * fade * 0.15;
}
// 35 particles rising vertically with sinusoidal horizontal drift;
// each particle uses bloom with edge fade and twinkle animation
const int PARTICLES = 35;
for (int i = 0; i < PARTICLES; i++) {
float fi = float(i);
float baseX = hash21(vec2(fi * 13.7 + uSeed, fi * 7.31));
float baseY = hash21(vec2(fi * 23.1 + uSeed * 1.9, fi * 11.3));
float riseSpeed = 0.04 + hash21(vec2(fi * 3.1, uSeed * 2.7)) * 0.06;
float driftAmp = 0.03 + hash21(vec2(fi * 8.9, uSeed)) * 0.05;
float driftFreq = 0.4 + hash21(vec2(fi * 5.3, uSeed * 4.1)) * 0.6;
float pX = baseX * asp + sin(t * driftFreq + fi * 2.3) * driftAmp * asp;
float pY = fract(baseY + t * riseSpeed);
float size = 0.005 + hash21(vec2(fi * 4.7, uSeed * 3.9)) * 0.010;
float bright = 0.12 + hash21(vec2(fi * 7.1, uSeed * 1.3)) * 0.25;
float edgeFade = smoothstep(0.0, 0.1, pY) * smoothstep(1.0, 0.9, pY);
float twinkle = 0.5 + 0.5 * sin(t * (1.8 + fi * 0.43) + fi * 3.1);
vec2 pPos = vec2(pX, pY);
float dist = length(ac - pPos);
col += uAccent.rgb * bloom(dist, size, size * 3.5) * bright * edgeFade * twinkle;
}
return col;
}
// ── Main ────────────────────────────────────────────────────────────────
void main()
{
vec2 uv = qt_TexCoord0;
float t = iTime * uSpeed;
vec3 bg = backgroundField(uv, iTime);
vec3 col;
if (uMode < 0.5) col = motifWelcome(uv, bg, t);
else if (uMode < 1.5) col = motifCardDatabase(uv, bg, t);
else if (uMode < 2.5) col = motifTheming(uv, bg, t);
else if (uMode < 3.5) col = motifAccount(uv, bg, t);
else if (uMode < 4.5) col = motifPreferences(uv, bg, t);
else col = motifFinish(uv, bg, t);
col *= mix(0.62, 1.0, vignette(uv));
fragColor = vec4(col, 1.0) * qt_Opacity;
}

View file

@ -0,0 +1,84 @@
#include "step_indicator_widget.h"
#include <QPainter>
#include <QPainterPath>
StepIndicatorWidget::StepIndicatorWidget(QWidget *parent) : QWidget(parent)
{
setFixedHeight(kDotDiameter + 2 * kVerticalMargin);
}
void StepIndicatorWidget::setStepCount(int count)
{
stepCount = qMax(0, count);
currentStep = qBound(0, currentStep, qMax(0, stepCount - 1));
updateGeometry();
update();
}
void StepIndicatorWidget::setCurrentStep(int index)
{
if (stepCount == 0) {
return;
}
currentStep = qBound(0, index, stepCount - 1);
update();
}
QSize StepIndicatorWidget::sizeHint() const
{
return minimumSizeHint();
}
QSize StepIndicatorWidget::minimumSizeHint() const
{
if (stepCount == 0) {
return QSize(0, height());
}
int width = kActiveDotWidth + (stepCount - 1) * kDotDiameter + (stepCount - 1) * kDotSpacing;
return QSize(width, height());
}
void StepIndicatorWidget::paintEvent(QPaintEvent * /*event*/)
{
if (stepCount == 0) {
return;
}
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
const QColor activeColor = palette().color(QPalette::Highlight);
// QPalette::Mid alpha-blended against a dark Window background reads as
// near-invisible (Mid is itself a dark grey in dark palettes -- see
// PaletteGenerator's satShadeLo/Dark roles). WindowText is guaranteed to
// contrast against Window in any theme by definition, so alpha-blending
// *that* instead keeps the dots visibly dim-but-present in both light and
// dark schemes. Same trick PaletteGenerator uses for placeholder text.
QColor inactiveColor = palette().color(QPalette::WindowText);
inactiveColor.setAlpha(100);
int totalWidth = 0;
for (int i = 0; i < stepCount; ++i) {
totalWidth += (i == currentStep) ? kActiveDotWidth : kDotDiameter;
if (i > 0) {
totalWidth += kDotSpacing;
}
}
int x = (width() - totalWidth) / 2;
const int y = height() / 2;
for (int i = 0; i < stepCount; ++i) {
const bool active = (i == currentStep);
const int dotWidth = active ? kActiveDotWidth : kDotDiameter;
QPainterPath path;
QRectF rect(x, y - kDotDiameter / 2.0, dotWidth, kDotDiameter);
path.addRoundedRect(rect, kDotDiameter / 2.0, kDotDiameter / 2.0);
painter.fillPath(path, active ? activeColor : inactiveColor);
x += dotWidth + kDotSpacing;
}
}

View file

@ -0,0 +1,34 @@
#ifndef STEP_INDICATOR_WIDGET_H
#define STEP_INDICATOR_WIDGET_H
#include <QWidget>
/** @brief Row of dots showing progress through a fixed-length sequence of steps,
* in the style of a mobile/OS setup flow. Purely presentational. */
class StepIndicatorWidget : public QWidget
{
Q_OBJECT
public:
explicit StepIndicatorWidget(QWidget *parent = nullptr);
void setStepCount(int count);
void setCurrentStep(int index);
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
protected:
void paintEvent(QPaintEvent *event) override;
private:
int stepCount = 0;
int currentStep = 0;
static constexpr int kDotDiameter = 8;
static constexpr int kActiveDotWidth = 22;
static constexpr int kDotSpacing = 10;
static constexpr int kVerticalMargin = 6;
};
#endif // STEP_INDICATOR_WIDGET_H

View file

@ -0,0 +1,188 @@
#include "playmat_collection_dialog.h"
#include "../../../client/settings/cache_settings.h"
#include "playmat_settings_dialog.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QListWidget>
#include <QPushButton>
#include <QVBoxLayout>
#include <libcockatrice/settings/interface_settings.h>
PlaymatCollectionDialog::PlaymatCollectionDialog(QWidget *parent) : QDialog(parent)
{
setMinimumWidth(420);
setupUi();
retranslateUi();
}
void PlaymatCollectionDialog::accept()
{
auto &interfaceSettings = SettingsCache::instance().userInterface();
interfaceSettings.setPlaymatFallbackList(playmats);
interfaceSettings.setPlaymatFallbackBehavior(modeCombo->currentData().toInt());
QDialog::accept();
}
int PlaymatCollectionDialog::currentRow() const
{
return playmatList->currentRow();
}
void PlaymatCollectionDialog::setupUi()
{
auto &interfaceSettings = SettingsCache::instance().userInterface();
playmats = interfaceSettings.getPlaymatFallbackList();
playmatList = new QListWidget;
for (const PlaymatInfo &entry : playmats) {
playmatList->addItem(entry.card.name);
}
connect(playmatList, &QListWidget::itemSelectionChanged, this, &PlaymatCollectionDialog::selectionChanged);
connect(playmatList, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *) { editPlaymat(); });
addButton = new QPushButton;
editButton = new QPushButton;
removeButton = new QPushButton;
moveUpButton = new QPushButton;
moveDownButton = new QPushButton;
connect(addButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::addPlaymat);
connect(editButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::editPlaymat);
connect(removeButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::removePlaymat);
connect(moveUpButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::movePlaymatUp);
connect(moveDownButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::movePlaymatDown);
auto *listButtons = new QVBoxLayout;
listButtons->addWidget(addButton);
listButtons->addWidget(editButton);
listButtons->addWidget(removeButton);
listButtons->addWidget(moveUpButton);
listButtons->addWidget(moveDownButton);
listButtons->addStretch();
auto *listRow = new QHBoxLayout;
listRow->addWidget(playmatList, 1);
listRow->addLayout(listButtons);
modeCombo = new QComboBox;
modeCombo->addItem(QString(), PlaymatFallbackModeFixed);
modeCombo->addItem(QString(), PlaymatFallbackModeRoundRobin);
modeCombo->addItem(QString(), PlaymatFallbackModeRandom);
const int modeIndex = modeCombo->findData(interfaceSettings.getPlaymatFallbackBehavior());
if (modeIndex >= 0) {
modeCombo->setCurrentIndex(modeIndex);
}
auto *modeRow = new QHBoxLayout;
modeLabel = new QLabel;
modeLabel->setBuddy(modeCombo);
modeRow->addWidget(modeLabel);
modeRow->addWidget(modeCombo, 1);
auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttonBox, &QDialogButtonBox::accepted, this, &PlaymatCollectionDialog::accept);
connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
auto *root = new QVBoxLayout;
root->addLayout(listRow);
root->addLayout(modeRow);
root->addWidget(buttonBox);
setLayout(root);
selectionChanged();
}
void PlaymatCollectionDialog::selectionChanged()
{
const bool hasSelection = playmatList->currentRow() >= 0;
editButton->setEnabled(hasSelection);
removeButton->setEnabled(hasSelection);
moveUpButton->setEnabled(hasSelection && playmatList->currentRow() > 0);
moveDownButton->setEnabled(hasSelection && playmatList->currentRow() < playmatList->count() - 1);
}
void PlaymatCollectionDialog::addPlaymat()
{
PlaymatSettingsDialog dialog(CardRef{}, PlaymatParams{}, this);
if (dialog.exec() == QDialog::Accepted) {
const CardRef card = dialog.card();
if (!card.isEmpty()) {
PlaymatInfo res = {card, dialog.params()};
playmats.append(res);
playmatList->addItem(res.card.name);
playmatList->setCurrentRow(playmatList->count() - 1);
}
}
}
void PlaymatCollectionDialog::editPlaymat()
{
const int row = currentRow();
if (row < 0) {
return;
}
const PlaymatInfo &current = playmats.at(row);
PlaymatSettingsDialog dialog(current.card, current.params, this);
if (dialog.exec() == QDialog::Accepted) {
const CardRef card = dialog.card();
if (card.isEmpty()) {
return; // Removal is handled by the Remove button
}
playmats[row] = {card, dialog.params()};
playmatList->item(row)->setText(card.name);
}
}
void PlaymatCollectionDialog::removePlaymat()
{
const int row = currentRow();
if (row < 0) {
return;
}
playmats.removeAt(row);
delete playmatList->takeItem(row);
selectionChanged();
}
void PlaymatCollectionDialog::movePlaymatUp()
{
const int row = currentRow();
if (row <= 0) {
return;
}
playmats.swapItemsAt(row, row - 1);
playmatList->insertItem(row - 1, playmatList->takeItem(row));
playmatList->setCurrentRow(row - 1);
selectionChanged();
}
void PlaymatCollectionDialog::movePlaymatDown()
{
const int row = currentRow();
if (row < 0 || row >= playmats.size() - 1) {
return;
}
playmats.swapItemsAt(row, row + 1);
playmatList->insertItem(row + 1, playmatList->takeItem(row));
playmatList->setCurrentRow(row + 1);
selectionChanged();
}
void PlaymatCollectionDialog::retranslateUi()
{
setWindowTitle(tr("Default Playmats"));
addButton->setText(tr("Add..."));
editButton->setText(tr("Edit..."));
removeButton->setText(tr("Remove"));
moveUpButton->setText(tr("Move Up"));
moveDownButton->setText(tr("Move Down"));
modeLabel->setText(tr("List mode:"));
modeCombo->setItemText(0, tr("Fixed (always the first entry)"));
modeCombo->setItemText(1, tr("Round-robin (cycle through entries)"));
modeCombo->setItemText(2, tr("Random (pick one per game)"));
}

View file

@ -0,0 +1,54 @@
#ifndef COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H
#define COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H
#include <QDialog>
#include <libcockatrice/utility/playmat_params.h>
class QComboBox;
class QLabel;
class QListWidget;
class QListWidgetItem;
class QPushButton;
/**
* @brief Dialog for editing the user-level playmat collection.
*
* The collection is the fallback used when a deck has no playmat of its own.
* It supports multiple entries and a pick mode (always first / round-robin /
* random). The dialog edits a working copy and writes it to the settings only
* when accepted.
*/
class PlaymatCollectionDialog : public QDialog
{
Q_OBJECT
public:
explicit PlaymatCollectionDialog(QWidget *parent = nullptr);
void accept() override;
private slots:
void addPlaymat();
void editPlaymat();
void removePlaymat();
void movePlaymatUp();
void movePlaymatDown();
void selectionChanged();
private:
void setupUi();
void retranslateUi();
int currentRow() const;
QList<PlaymatInfo> playmats; ///< Working copy edited by the dialog.
QListWidget *playmatList;
QComboBox *modeCombo;
QLabel *modeLabel;
QPushButton *addButton;
QPushButton *editButton;
QPushButton *removeButton;
QPushButton *moveUpButton;
QPushButton *moveDownButton;
};
#endif // COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H

View file

@ -0,0 +1,381 @@
#include "playmat_preview_widget.h"
#include "../cards/art_crop_attribution.h"
#include "playmat_utils.h"
#include <QKeyEvent>
#include <QLinearGradient>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QWheelEvent>
#include <cmath>
namespace
{
// Mirrors the dialog/proto clamps so gestures can never produce an
// out of range parameter. The zoom FLOOR is dynamic: see
// playmatClampedZoom(), zooming out stops where the sampling window would
// exceed the card itself, so there is no dead range at the bottom end.
constexpr qreal MAX_MARGIN = 0.95;
// Range of in game stack+table aspect ratios worth designing for, derived
// from PlayerGraphicsItem::paint()'s combinedArea = stack ∪ table:
// height = 10 + 30 + 3*102 + 2*30 = 406 (TableZone rows)
// width = 1.5*72 + (20 + 5*72 + 15) = 503 (StackZone + TableZone
// at MIN_WIDTH)
// The area's shape depends on GAME CONTENT (played card columns widen the
// table by ~107 px each), not on the window size. Fresh board ≈ 503/406,
// a table grown to roughly double its minimum width ≈ 2.2.
constexpr qreal MIN_TABLE_ASPECT = 503.0 / 406.0; // fresh board: most generous framing
constexpr qreal MAX_TABLE_ASPECT = 2.2; // well developed, wide table
// Keyboard nudge steps (viewport convention: Down looks further down).
constexpr qreal KEY_PAN_MARGIN_STEP = 0.005;
constexpr qreal KEY_PAN_OFFSET_STEP = 0.01;
constexpr qreal KEY_ZOOM_STEP = 1.05;
constexpr qreal WHEEL_ZOOM_BASE = 1.15; // zoom factor per wheel notch
} // namespace
PlaymatPreviewWidget::PlaymatPreviewWidget(QWidget *parent) : QWidget(parent)
{
// The crop is square and drawn contain fit, so the height decides its
// on screen size, keep it generous but let the dialog compress on small
// or high DPI screens
setMinimumSize(400, 180);
QSizePolicy sp(QSizePolicy::Expanding, QSizePolicy::Expanding);
setSizePolicy(sp);
setFocusPolicy(Qt::StrongFocus);
setCursor(Qt::OpenHandCursor);
setAccessibleName(tr("Playmat crop"));
setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
}
void PlaymatPreviewWidget::setPixmap(const QPixmap &pixmap)
{
sourcePixmap = pixmap;
setCursor(sourcePixmap.isNull() ? Qt::ArrowCursor : Qt::OpenHandCursor);
update();
}
void PlaymatPreviewWidget::setParams(const PlaymatParams &p)
{
params = p;
setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
update();
}
void PlaymatPreviewWidget::setAttribution(const QString &attribution)
{
attributionText = attribution;
update();
}
QRectF PlaymatPreviewWidget::activePlayArea() const
{
// The viewport is a frame shaped like a fresh board's stack+table area
// (kMinTableAspect): the most generous framing the game will produce.
// Dimmed strips mark where a wider, developed table crops further.
const QRectF cardRect = QRectF(rect()).adjusted(3, 2, -3, -2);
return PlaymatUtils::aspectFitRect(cardRect.adjusted(6, 4, -4, -4), MIN_TABLE_ASPECT);
}
qreal PlaymatPreviewWidget::samplingWindowSide() const
{
if (sourcePixmap.isNull()) {
return 0.0;
}
// Same clamped window the render path uses, gestures and painting must
// never disagree about geometry.
return PlaymatUtils::playmatWindowSide(sourcePixmap.size(), params);
}
qreal PlaymatPreviewWidget::widgetToSourceScale() const
{
const qreal cropSide = samplingWindowSide();
const QRectF area = activePlayArea();
if (cropSide <= 0.0 || area.isEmpty()) {
return 0.0;
}
// Mirror coverFitRect(): the square crop into the (wider) viewport fills
// its width.
return area.width() / cropSide;
}
void PlaymatPreviewWidget::applyCropDelta(qreal dMarginL, qreal dMarginR, qreal dOffset, qreal zoomFactor)
{
PlaymatParams next = params;
if (dMarginL + dMarginR == 0.0) {
// Pure horizontal pan rebalances the margins along their
// sum constant segment. Individual bounds must not break that
// invariant, otherwise repeated corner drags let one margin grow
// without end, collapsing the viewing window and desynchronizing the
// visual zoom from the readout.
const qreal sum = params.marginPctL + params.marginPctR;
const qreal lo = qMax(0.0, sum - MAX_MARGIN);
const qreal hi = qMin(sum, MAX_MARGIN);
next.marginPctL = qBound(lo, params.marginPctL + dMarginL, hi);
next.marginPctR = sum - next.marginPctL;
} else {
next.marginPctL = qBound(0.0, params.marginPctL + dMarginL, MAX_MARGIN);
next.marginPctR = qBound(0.0, params.marginPctR + dMarginR, MAX_MARGIN);
}
next.verticalOffset = qBound(0.0, params.verticalOffset + dOffset, 1.0);
// Clamp through the shared helper so the floor tracks the new margins:
// zooming out stops exactly where the window reaches the card bounds.
next.zoom = params.zoom * zoomFactor;
if (!sourcePixmap.isNull()) {
next.zoom = PlaymatUtils::playmatClampedZoom(sourcePixmap.size(), next);
}
if (sameCrop(next, params)) {
return;
}
params = next;
setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
update();
emit paramsEdited(params);
}
bool PlaymatPreviewWidget::sameCrop(const PlaymatParams &a, const PlaymatParams &b) const
{
// Exact comparison on purpose: clamped assignments yield identical bits,
// while qFuzzyCompare based equality misbehaves around zero
return a.marginPctL == b.marginPctL && a.marginPctR == b.marginPctR && a.verticalOffset == b.verticalOffset &&
a.zoom == b.zoom;
}
void PlaymatPreviewWidget::restoreSnapshot()
{
// The snapshot only ever holds values that passed the gesture clamps,
// so it is safe to restore verbatim
if (sameCrop(paramsAtFocusIn, params)) {
return;
}
params = paramsAtFocusIn;
setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
update();
emit paramsEdited(params);
}
void PlaymatPreviewWidget::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing);
const QRect rect = this->rect();
const QColor accentColor(100, 116, 139);
// Background
const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2);
QLinearGradient bg(cardRect.topLeft(), cardRect.topRight());
bg.setColorAt(0, accentColor.darker(320));
bg.setColorAt(1, QColor(18, 22, 30));
painter.setPen(Qt::NoPen);
painter.setBrush(bg);
painter.drawRoundedRect(cardRect, 6, 6);
painter.setBrush(accentColor);
painter.drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2);
if (sourcePixmap.isNull()) {
painter.setPen(QColor(150, 150, 150));
painter.drawText(rect, Qt::AlignCenter, tr("No card selected"));
return;
}
const QRectF playArea = activePlayArea();
// Exactly the game's pipeline (player_graphics_item): cover fit the crop
// into the table shaped viewport, centered, so the frame shows precisely
// what a minimum aspect window shows, and dragging moves the art behind
// the fixed frame.
const QRectF srcRect = PlaymatUtils::computeArtSourceRect(sourcePixmap.size(), params);
const QRectF dstRect = PlaymatUtils::coverFitRect(playArea, srcRect.size());
painter.setClipRect(playArea.toRect());
painter.drawPixmap(dstRect, sourcePixmap, srcRect);
// Wider (developed) tables crop further: mark where a kMaxTableAspect
// board stops. Palette driven so theme authors can recolor the markers.
const qreal wideBandHeight = playArea.height() * (MIN_TABLE_ASPECT / MAX_TABLE_ASPECT);
const qreal stripHeight = (playArea.height() - wideBandHeight) / 2.0;
QColor stripColor = palette().color(QPalette::Window);
stripColor.setAlpha(150);
painter.fillRect(QRectF(playArea.left(), playArea.top(), playArea.width(), stripHeight), stripColor);
painter.fillRect(QRectF(playArea.left(), playArea.bottom() - stripHeight, playArea.width(), stripHeight),
stripColor);
QColor hairlineColor = palette().color(QPalette::Highlight);
hairlineColor.setAlpha(110);
painter.setPen(QPen(hairlineColor, 1));
painter.drawLine(QPointF(playArea.left(), playArea.top() + stripHeight),
QPointF(playArea.right(), playArea.top() + stripHeight));
painter.drawLine(QPointF(playArea.left(), playArea.bottom() - stripHeight),
QPointF(playArea.right(), playArea.bottom() - stripHeight));
// Draw zone divider: stack is roughly the left portion
const double stackWidthRatio = 0.18; // Stack is about 18% of total play area
const double stackDividerX = playArea.left() + playArea.width() * stackWidthRatio;
// Subtle semi transparent overlays to distinguish zones
// Stack zone overlay (slightly darker)
QRectF stackOverlay(playArea.left(), playArea.top(), playArea.width() * stackWidthRatio, playArea.height());
painter.fillRect(stackOverlay, QColor(0, 0, 0, 40));
// Table zone overlay (very subtle)
QRectF tableOverlay(stackDividerX, playArea.top(), playArea.width() * (1.0 - stackWidthRatio), playArea.height());
painter.fillRect(tableOverlay, QColor(0, 0, 0, 20));
// Zone divider line
painter.setPen(QPen(QColor(255, 255, 255, 50), 1));
painter.drawLine(QPointF(stackDividerX, playArea.top()), QPointF(stackDividerX, playArea.bottom()));
// Land divider line (about 60% down the table area)
const double landDividerY = playArea.top() + playArea.height() * 0.65;
painter.setPen(QPen(QColor(255, 255, 255, 30), 1));
painter.drawLine(QPointF(stackDividerX, landDividerY), QPointF(playArea.right(), landDividerY));
painter.setClipping(false);
// Border around the viewport = boundary of every plausible framing.
painter.setPen(QPen(QColor(70, 80, 95, 120), 1));
painter.setBrush(Qt::NoBrush);
painter.drawRoundedRect(playArea.adjusted(0, 0, -1, -1), 3, 3);
// Visible keyboard focus per the focus cursor contract, Tab must show
// where the keys land
if (hasFocus()) {
QPen focusPen(palette().color(QPalette::Highlight), 2);
painter.setPen(focusPen);
painter.drawRoundedRect(playArea.adjusted(-1, -1, 1, 1), 3, 3);
}
paintArtAttribution(painter, playArea, attributionText, Qt::AlignRight | Qt::AlignBottom, 0.8);
// Zoom readout so the gesture has a visible, stable counterpart.
QColor ink = palette().color(QPalette::WindowText);
ink.setAlpha(160);
painter.setPen(ink);
painter.drawText(QPointF(playArea.left() + 8, playArea.bottom() - 8),
tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
}
void PlaymatPreviewWidget::mousePressEvent(QMouseEvent *event)
{
if (event->button() != Qt::LeftButton || sourcePixmap.isNull() || samplingWindowSide() <= 0.0) {
QWidget::mousePressEvent(event);
return;
}
lastDragPos = event->pos();
setCursor(Qt::ClosedHandCursor);
event->accept();
}
void PlaymatPreviewWidget::mouseMoveEvent(QMouseEvent *event)
{
if (!(event->buttons() & Qt::LeftButton) || sourcePixmap.isNull()) {
QWidget::mouseMoveEvent(event);
return;
}
const QPointF delta = QPointF(event->pos() - lastDragPos);
lastDragPos = event->pos();
const qreal scale = widgetToSourceScale();
// Vertical travel of the SAMPLING window: verticalOffset moves its top
// edge by exactly this much per unit, identical to the render path.
// Windows taller than the art (square/landscape sources zoomed out)
// leave no travel, vertical drags are then boundary no ops.
const qreal travel = static_cast<qreal>(sourcePixmap.height()) - samplingWindowSide();
if (scale <= 0.0) {
event->accept();
return;
}
// Dragging moves the ART with the cursor, so the viewing window slides the
// other way. Horizontal panning rebalances the margins (their sum, hence
// the window width, stays constant), vertical panning moves the window's
// top edge within its available travel.
const qreal sourceW = sourcePixmap.width();
const qreal dMargin = -(delta.x() / scale) / sourceW;
const qreal dOffset = travel > 0.5 ? -(delta.y() / scale) / travel : 0.0;
applyCropDelta(dMargin, -dMargin, dOffset, 1.0);
event->accept();
}
void PlaymatPreviewWidget::mouseReleaseEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton) {
setCursor(sourcePixmap.isNull() ? Qt::ArrowCursor : Qt::OpenHandCursor);
event->accept();
return;
}
QWidget::mouseReleaseEvent(event);
}
void PlaymatPreviewWidget::wheelEvent(QWheelEvent *event)
{
if (sourcePixmap.isNull() || samplingWindowSide() <= 0.0) {
QWidget::wheelEvent(event);
return;
}
const qreal notches = static_cast<qreal>(event->angleDelta().y()) / 120.0;
if (notches == 0.0) {
event->accept();
return;
}
applyCropDelta(0.0, 0.0, 0.0, std::pow(WHEEL_ZOOM_BASE, notches));
event->accept();
}
void PlaymatPreviewWidget::keyPressEvent(QKeyEvent *event)
{
if (sourcePixmap.isNull()) {
QWidget::keyPressEvent(event);
return;
}
switch (event->key()) {
case Qt::Key_Escape:
if (sameCrop(params, paramsAtFocusIn)) {
// Nothing to undo on this surface, let the event reach the
// dialog so Esc keeps its close meaning there
QWidget::keyPressEvent(event);
return;
}
restoreSnapshot();
break;
case Qt::Key_Backspace:
restoreSnapshot();
break;
case Qt::Key_Left:
applyCropDelta(-KEY_PAN_MARGIN_STEP, KEY_PAN_MARGIN_STEP, 0.0, 1.0);
break;
case Qt::Key_Right:
applyCropDelta(KEY_PAN_MARGIN_STEP, -KEY_PAN_MARGIN_STEP, 0.0, 1.0);
break;
case Qt::Key_Up:
applyCropDelta(0.0, 0.0, -KEY_PAN_OFFSET_STEP, 1.0);
break;
case Qt::Key_Down:
applyCropDelta(0.0, 0.0, KEY_PAN_OFFSET_STEP, 1.0);
break;
case Qt::Key_Plus:
case Qt::Key_Equal:
applyCropDelta(0.0, 0.0, 0.0, KEY_ZOOM_STEP);
break;
case Qt::Key_Minus:
applyCropDelta(0.0, 0.0, 0.0, 1.0 / KEY_ZOOM_STEP);
break;
default:
QWidget::keyPressEvent(event);
return;
}
event->accept();
}
void PlaymatPreviewWidget::focusInEvent(QFocusEvent *event)
{
// Snapshot for the Esc or Backspace reset, restoring whatever the user
// had when the surface took focus
paramsAtFocusIn = params;
QWidget::focusInEvent(event);
}

View file

@ -0,0 +1,61 @@
#ifndef COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H
#define COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H
#include <QFocusEvent>
#include <QPixmap>
#include <QWidget>
#include <libcockatrice/deck_list/deck_list.h>
/**
* @brief Interactive crop surface showing how a playmat card art will appear
* across the combined table + stack play area.
*
* Renders a fixed frame shaped like a fresh board's stack+table area (the
* most generous framing the game produces): it shows the tallest slice of
* the square crop in normal play, with dimmed strips marking where a wider,
* developed table crops further, exactly the game's own render pipeline.
* The widget doubles as the editor's primary crop control: dragging pans the
* art behind the frame, the wheel zooms, and arrow keys nudge, mirroring
* the stored parameters (margins pan horizontally, verticalOffset
* vertically, zoom scales) so no separate numeric controls are needed.
*/
class PlaymatPreviewWidget : public QWidget
{
Q_OBJECT
public:
explicit PlaymatPreviewWidget(QWidget *parent = nullptr);
void setPixmap(const QPixmap &pixmap);
void setParams(const PlaymatParams &params);
void setAttribution(const QString &attribution);
signals:
/** @brief Emitted whenever direct manipulation (drag, wheel, keys) changes the crop parameters. */
void paramsEdited(const PlaymatParams &params);
protected:
void paintEvent(QPaintEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
void focusInEvent(QFocusEvent *event) override;
private:
QRectF activePlayArea() const; ///< destination rect used for rendering AND gesture math
qreal samplingWindowSide() const; ///< clamped square window side, shared with the render path
qreal widgetToSourceScale() const;
void applyCropDelta(qreal dMarginL, qreal dMarginR, qreal dOffset, qreal zoomFactor);
bool sameCrop(const PlaymatParams &a, const PlaymatParams &b) const;
void restoreSnapshot();
QPixmap sourcePixmap;
PlaymatParams params;
PlaymatParams paramsAtFocusIn; ///< crop as of the latest focus gain, restored by Esc or Backspace
QString attributionText;
QPoint lastDragPos; ///< widget space position of the previous mouse move while panning
};
#endif // COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H

View file

@ -0,0 +1,324 @@
#include "playmat_settings_dialog.h"
#include "../../card_picture_loader/card_picture_loader.h"
#include "../cards/art_crop_attribution.h"
#include "../utility/completer_utils.h"
#include "card_database_display_model.h"
#include "card_database_model.h"
#include "playmat_preview_widget.h"
#include <QCheckBox>
#include <QComboBox>
#include <QCompleter>
#include <QDialogButtonBox>
#include <QDoubleSpinBox>
#include <QFormLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
PlaymatSettingsDialog::PlaymatSettingsDialog(const CardRef &initialCard,
const PlaymatParams &initialParams,
QWidget *parent)
: QDialog(parent), currentCard(initialCard), currentParams(initialParams)
{
setMinimumWidth(500);
setupUi();
// Seed UI from initial values
if (!initialCard.name.isEmpty()) {
searchBar->setText(initialCard.name);
onCardNameChanged(initialCard.name);
// onCardNameChanged leaves the printing combo on the first printing in
// the database, which would silently change the deck's stored playmat
// card on accept. Restore the stored printing when it resolves locally.
const int storedPrintingIndex = providerComboBox->findData(initialCard.providerId);
if (storedPrintingIndex != -1) {
providerComboBox->setCurrentIndex(storedPrintingIndex);
} else {
// Stored printing not in the local database: keep it rather than
// silently substituting the first printing.
currentCard.providerId = initialCard.providerId;
reloadPreview();
}
}
retranslateUi();
}
CardRef PlaymatSettingsDialog::card() const
{
return currentCard;
}
PlaymatParams PlaymatSettingsDialog::params() const
{
return currentParams;
}
QDoubleSpinBox *PlaymatSettingsDialog::makeSpinBox(double min, double max, double value, double step)
{
auto *spin = new QDoubleSpinBox;
spin->setRange(min, max);
spin->setSingleStep(step);
spin->setDecimals(3);
spin->setValue(value);
return spin;
}
void PlaymatSettingsDialog::initializeSearchBar()
{
searchBar = new QLineEdit;
cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this);
cardDatabaseDisplayModel = new CardDatabaseDisplayModel(this);
cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel);
const CardCompleterSetup cardSetup = createCardCompleter(cardDatabaseDisplayModel, this, 15);
searchModel = cardSetup.searchModel;
proxyModel = cardSetup.proxyModel;
completer = cardSetup.completer;
searchBar->setCompleter(completer);
connectCardCompleterSearch(searchBar, cardSetup);
connect(completer, static_cast<void (QCompleter::*)(const QString &)>(&QCompleter::activated), this,
[this](const QString &completion) {
if (searchBar->text() != completion) {
searchBar->setText(completion);
searchBar->setCursorPosition(searchBar->text().length());
}
onCardNameChanged(completion);
});
connect(searchBar, &QLineEdit::returnPressed, this, [this]() { onCardNameChanged(searchBar->text()); });
}
void PlaymatSettingsDialog::setupUi()
{
initializeSearchBar();
providerComboBox = new QComboBox;
connect(providerComboBox, &QComboBox::currentIndexChanged, this, [this]() {
currentCard.providerId = providerComboBox->currentData().toString();
reloadPreview();
});
auto *form = new QFormLayout;
controlsForm = form;
cardNameLabel = new QLabel;
printingLabel = new QLabel;
form->addRow(cardNameLabel, searchBar);
form->addRow(printingLabel, providerComboBox);
// Numerical editors expose the raw PlaymatParams for precise input. They
// share the same form as the rows above so every field lines up on one
// label column. They stay hidden until requested since the crop surface
// is the primary control.
marginLSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctL, 0.01);
marginRSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctR, 0.01);
verticalOffsetSpin = makeSpinBox(0.0, 1.0, currentParams.verticalOffset, 0.01);
zoomSpin = makeSpinBox(0.1, 4.0, currentParams.zoom, 0.05);
leftMarginLabel = new QLabel;
rightMarginLabel = new QLabel;
verticalOffsetLabel = new QLabel;
zoomLabel = new QLabel;
showNumericEditorsCheck = new QCheckBox;
form->addRow(showNumericEditorsCheck);
form->addRow(leftMarginLabel, marginLSpin);
form->addRow(rightMarginLabel, marginRSpin);
form->addRow(verticalOffsetLabel, verticalOffsetSpin);
form->addRow(zoomLabel, zoomSpin);
controlsGroup = new QGroupBox;
controlsGroup->setLayout(form);
preview = new PlaymatPreviewWidget;
preview->setParams(currentParams);
auto *previewLayout = new QVBoxLayout;
previewLayout->addWidget(preview);
previewCaptionLabel = new QLabel;
previewCaptionLabel->setAlignment(Qt::AlignCenter);
previewCaptionLabel->setWordWrap(true);
previewLayout->addWidget(previewCaptionLabel);
previewGroup = new QGroupBox;
previewGroup->setLayout(previewLayout);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
removeButton = new QPushButton;
buttons->addButton(removeButton, QDialogButtonBox::ResetRole);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
connect(removeButton, &QPushButton::clicked, this, [this]() {
currentCard = CardRef{}; // empty signals removal
accept();
});
// The crop surface is the primary control: dragging pans, wheel/keys zoom,
// editing exactly the same stored parameters the numeric fields do.
connect(preview, &PlaymatPreviewWidget::paramsEdited, this, [this](const PlaymatParams &edited) {
currentParams = edited;
QSignalBlocker blockMarginL(marginLSpin);
QSignalBlocker blockMarginR(marginRSpin);
QSignalBlocker blockOffset(verticalOffsetSpin);
QSignalBlocker blockZoom(zoomSpin);
marginLSpin->setValue(edited.marginPctL);
marginRSpin->setValue(edited.marginPctR);
verticalOffsetSpin->setValue(edited.verticalOffset);
zoomSpin->setValue(edited.zoom);
});
connect(showNumericEditorsCheck, &QCheckBox::toggled, this, &PlaymatSettingsDialog::setNumericEditorsVisible);
setNumericEditorsVisible(false);
connect(marginLSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged);
connect(marginRSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged);
connect(verticalOffsetSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged);
connect(zoomSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged);
// The crop surface leads visually, card selection supports it below.
auto *root = new QVBoxLayout;
root->addWidget(previewGroup);
root->addWidget(controlsGroup);
root->addWidget(buttons);
setLayout(root);
}
void PlaymatSettingsDialog::populateProviderCombo(const QString &cardName)
{
providerComboBox->clear();
auto card = CardDatabaseManager::query()->getCard({cardName});
const auto &sets = card.getInfo().getSets();
for (const auto &printings : sets) {
for (const auto &p : printings) {
QString setName = p.getSet()->getLongName();
QString collector = p.getProperty("num");
QString uuid = p.getUuid();
QString label = setName;
if (!collector.isEmpty()) {
label += " #" + collector;
}
providerComboBox->addItem(label, uuid);
}
}
}
void PlaymatSettingsDialog::onCardNameChanged(const QString &name)
{
if (name.isEmpty()) {
currentPixmap = QPixmap();
preview->setPixmap(currentPixmap);
return;
}
const ExactCard card = CardDatabaseManager::query()->getCard({name});
if (!card) {
currentPixmap = QPixmap();
preview->setPixmap(currentPixmap);
providerComboBox->clear();
return;
}
currentCard.name = name;
populateProviderCombo(name);
if (providerComboBox->count() == 0) {
currentPixmap = QPixmap();
preview->setPixmap(currentPixmap);
currentCard.providerId.clear();
return;
}
currentCard.providerId = providerComboBox->currentData().toString();
reloadPreview();
}
void PlaymatSettingsDialog::reloadPreview()
{
if (currentCard.name.isEmpty()) {
return;
}
ExactCard card = CardDatabaseManager::query()->getCard({currentCard.name, currentCard.providerId});
if (!card) {
return;
}
disconnect(pixmapUpdatedConnection);
QPixmap fullRes;
CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040));
if (fullRes.isNull()) {
CardInfo *cardInfo = card.getCardPtr().data();
if (cardInfo) {
pixmapUpdatedConnection = connect(cardInfo, &CardInfo::pixmapUpdated, this, [this]() { reloadPreview(); });
}
return;
}
currentPixmap = fullRes;
preview->setPixmap(currentPixmap);
preview->setParams(currentParams);
preview->setAttribution(buildArtAttribution(card));
}
void PlaymatSettingsDialog::onParamChanged()
{
currentParams.marginPctL = marginLSpin->value();
currentParams.marginPctR = marginRSpin->value();
currentParams.verticalOffset = verticalOffsetSpin->value();
currentParams.zoom = zoomSpin->value();
preview->setParams(currentParams);
}
void PlaymatSettingsDialog::setNumericEditorsVisible(bool visible)
{
controlsForm->setRowVisible(leftMarginLabel, visible);
controlsForm->setRowVisible(rightMarginLabel, visible);
controlsForm->setRowVisible(verticalOffsetLabel, visible);
controlsForm->setRowVisible(zoomLabel, visible);
// A QDialog never resizes itself when its content requirements change,
// so revealing the editors would squeeze the crop group until the info
// caption ran into the preview. Re-fit the dialog to the new size hint.
adjustSize();
}
void PlaymatSettingsDialog::retranslateUi()
{
setWindowTitle(tr("Playmat Settings"));
searchBar->setPlaceholderText(tr("Type a card name..."));
cardNameLabel->setText(tr("Card name:"));
printingLabel->setText(tr("Printing:"));
showNumericEditorsCheck->setText(tr("Show numerical editors"));
leftMarginLabel->setText(tr("Left margin (%):"));
rightMarginLabel->setText(tr("Right margin (%):"));
verticalOffsetLabel->setText(tr("Vertical offset:"));
zoomLabel->setText(tr("Zoom:"));
controlsGroup->setTitle(tr("Card"));
previewGroup->setTitle(tr("Crop"));
previewCaptionLabel->setText(
tr("Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. "
"Dimmed strips mark where a wider table crops further."));
removeButton->setText(tr("Remove Playmat"));
}

View file

@ -0,0 +1,94 @@
#ifndef COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H
#define COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H
#include <QDialog>
#include <QPixmap>
#include <libcockatrice/deck_list/deck_list.h>
class QCheckBox;
class QComboBox;
class QCompleter;
class QDoubleSpinBox;
class QFormLayout;
class QGroupBox;
class QLabel;
class QLineEdit;
class QPushButton;
class QWidget;
class CardDatabaseModel;
class CardDatabaseDisplayModel;
class CardSearchModel;
class CardCompleterProxyModel;
class PlaymatPreviewWidget;
/**
* @brief Dialog for configuring the playmat card art for a deck.
*
* The crop surface is the primary control: drag to pan the visible art,
* scroll (or +/- keys) to zoom, arrow keys to nudge. Card name and printing
* are selected below. A checkbox reveals optional numerical editors for the
* raw PlaymatParams. These controls edit the same stored PlaymatParams that
* ship in deck files and player properties.
*/
class PlaymatSettingsDialog : public QDialog
{
Q_OBJECT
public:
explicit PlaymatSettingsDialog(const CardRef &initialCard = {},
const PlaymatParams &initialParams = {},
QWidget *parent = nullptr);
CardRef card() const;
PlaymatParams params() const;
private slots:
void onCardNameChanged(const QString &name);
void onParamChanged();
void reloadPreview();
private:
void setupUi();
void populateProviderCombo(const QString &cardName);
void initializeSearchBar();
void retranslateUi();
void setNumericEditorsVisible(bool visible);
QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step);
QLineEdit *searchBar;
QCompleter *completer;
CardDatabaseModel *cardDatabaseModel;
CardDatabaseDisplayModel *cardDatabaseDisplayModel;
CardSearchModel *searchModel;
CardCompleterProxyModel *proxyModel;
QComboBox *providerComboBox;
QMetaObject::Connection pixmapUpdatedConnection;
QLabel *cardNameLabel;
QLabel *printingLabel;
QLabel *previewCaptionLabel;
QCheckBox *showNumericEditorsCheck;
QFormLayout *controlsForm;
QLabel *leftMarginLabel;
QLabel *rightMarginLabel;
QLabel *verticalOffsetLabel;
QLabel *zoomLabel;
QGroupBox *controlsGroup;
QGroupBox *previewGroup;
QPushButton *removeButton;
QDoubleSpinBox *marginLSpin;
QDoubleSpinBox *marginRSpin;
QDoubleSpinBox *verticalOffsetSpin;
QDoubleSpinBox *zoomSpin;
PlaymatPreviewWidget *preview;
QPixmap currentPixmap;
CardRef currentCard;
PlaymatParams currentParams;
};
#endif // COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H

View file

@ -0,0 +1,140 @@
#ifndef COCKATRICE_PLAYMAT_UTILS_H
#define COCKATRICE_PLAYMAT_UTILS_H
#include <QRectF>
#include <QSize>
#include <QSizeF>
#include <libcockatrice/deck_list/deck_list.h>
namespace PlaymatUtils
{
/** @brief Upper bound for zooming into the playmat art. */
constexpr qreal MAX_ZOOM = 4.0;
/**
* @brief Width of the outer viewing window: full card width trimmed by the
* horizontal margins. Guarded against margins summing to >= 1.
*/
inline qreal playmatVisibleWidth(const QSize &fullCardSize, const PlaymatParams &params)
{
const qreal srcW = fullCardSize.width();
const qreal marginL = params.marginPctL * srcW;
const qreal marginR = params.marginPctR * srcW;
return qMax(0.0, srcW - marginL - marginR);
}
/**
* @brief Zoom clamped to the range where every step renders differently.
*
* The square sampling window is visibleWidth / zoom, zooming out past
* visibleWidth / min(card width, height) would sample beyond the card itself,
* which both looks broken and makes whole ranges of the parameter dead. The
* floor is therefore derived from the actual image instead of a static value,
* and is shared verbatim by the render path and the editor's gesture math so
* the two can never disagree.
*/
inline qreal playmatClampedZoom(const QSize &fullCardSize, const PlaymatParams &params)
{
const qreal minDim = qMin<qreal>(fullCardSize.width(), fullCardSize.height());
const qreal visibleW = playmatVisibleWidth(fullCardSize, params);
const qreal zoomOutFloor = (minDim > 0.0 && visibleW > 0.0) ? visibleW / minDim : 1.0;
// The floor deliberately bypasses MAX_ZOOM: when the art is much wider
// than tall, keeping the square window inside it requires more than 4x
// zoom-out, and honoring that larger floor keeps side within
// min(card width, height). Zooming IN is still capped at MAX_ZOOM.
return qMin(MAX_ZOOM, qMax(params.zoom, zoomOutFloor));
}
/**
* @brief Side of the square sampling window actually rendered for these
* parameters. Never exceeds either card dimension, so the source rect
* always lies within the image (vertical travel remains for panning
* whenever the art is taller than it is wide).
*/
inline qreal playmatWindowSide(const QSize &fullCardSize, const PlaymatParams &params)
{
const qreal visibleW = playmatVisibleWidth(fullCardSize, params);
if (visibleW <= 0.0) {
return 0.0;
}
return visibleW / playmatClampedZoom(fullCardSize, params);
}
/**
* @brief Computes the source region of the full resolution card image to use as a playmat.
*
* Parameters are relative to the full card image. horizontal margins trim the
* card borders (shifting them pans the window), verticalOffset places the top
* edge of the sampling window within its available travel, and zoom scales
* into the trimmed span. The result always lies within the card image bounds.
*
* @param fullCardSize Size of the full card image.
* @param params Positioning parameters.
* @return Source rectangle in full card image pixel coordinates.
*/
inline QRectF computeArtSourceRect(const QSize &fullCardSize, const PlaymatParams &params)
{
const qreal srcW = fullCardSize.width();
const qreal srcH = fullCardSize.height();
// Square sampling window, keeps art unskewed, never exceeds the card on
// either axis thanks to the zoom floor in playmatWindowSide().
const qreal side = playmatWindowSide(fullCardSize, params);
// verticalOffset places the TOP edge of the sampling window itself within
// its travel, so the full [0, 1] parameter range is live at every zoom and
// the window can always reach the very top (0.0) and bottom (1.0) of the
// art.
const qreal offset = qBound(0.0, params.verticalOffset, 1.0);
const qreal y = offset * qMax(0.0, srcH - side);
// Horizontally the sampling window sits centered inside the trimmed span
// (margins pan it), zooming out can make it wider than that span, so it
// is then kept within the image, an edge stop, never an invalid rect.
const qreal outerW = playmatVisibleWidth(fullCardSize, params);
const qreal x = qBound(0.0, params.marginPctL * srcW + (outerW - side) / 2.0, qMax(0.0, srcW - side));
return QRectF(x, y, side, side);
}
/**
* @brief Returns the destination rectangle that fits a source of the given aspect
* ratio into dstArea using "cover" semantics (no distortion, overflows cropped).
*
* @param dstArea Area to fill.
* @param srcSize Size of the source, only its aspect ratio matters.
* @return Destination rectangle centered in dstArea.
*/
inline QRectF coverFitRect(const QRectF &dstArea, const QSizeF &srcSize)
{
const qreal srcAspect = srcSize.width() / srcSize.height();
const qreal dstAspect = dstArea.width() / dstArea.height();
if (srcAspect > dstAspect) {
const qreal dstW = dstArea.height() * srcAspect;
return QRectF(dstArea.left() + (dstArea.width() - dstW) / 2.0, dstArea.top(), dstW, dstArea.height());
}
const qreal dstH = dstArea.width() / srcAspect;
return QRectF(dstArea.left(), dstArea.top() + (dstArea.height() - dstH) / 2.0, dstArea.width(), dstH);
}
/**
* @brief Fits a rectangle of the given aspect ratio into dstArea, centered,
* touching the constraining dimension ("aspect fit" of the FRAME
* itself, not of a source image).
*/
inline QRectF aspectFitRect(const QRectF &dstArea, qreal aspect)
{
if (aspect <= 0.0) {
return dstArea;
}
qreal w = qMin(dstArea.width(), dstArea.height() * aspect);
qreal h = w / aspect;
return QRectF(dstArea.left() + (dstArea.width() - w) / 2.0, dstArea.top() + (dstArea.height() - h) / 2.0, w, h);
}
} // namespace PlaymatUtils
#endif // COCKATRICE_PLAYMAT_UTILS_H

View file

@ -1,6 +1,7 @@
#include "printing_selector_card_overlay_widget.h"
#include "../../../client/settings/cache_settings.h"
#include "../cards/card_info_picture_widget.h"
#include "printing_selector_card_display_widget.h"
#include <QImageReader>

View file

@ -13,6 +13,9 @@
#include <QDesktopServices>
#include <QMouseEvent>
#include <QScrollBar>
#include <QTimer>
#include <QUrl>
#include <QUrlQuery>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/server/remote/user_level.h>
#include <libcockatrice/settings/chat_settings.h>
@ -48,9 +51,12 @@ ChatView::ChatView(TabSupervisor *_tabSupervisor, AbstractGame *_game, bool _sho
viewport()->setCursor(Qt::IBeamCursor);
setReadOnly(true);
setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::LinksAccessibleByMouse);
setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard);
setOpenLinks(false);
connect(this, &ChatView::anchorClicked, this, &ChatView::openLink);
connect(verticalScrollBar(), &QScrollBar::rangeChanged, this, &ChatView::onScrollBarRangeChanged);
connect(verticalScrollBar(), &QScrollBar::valueChanged, this, &ChatView::onScrollBarValueChanged);
}
void ChatView::adjustColorsToPalette()
@ -151,7 +157,7 @@ void ChatView::appendHtml(const QString &html)
bool atBottom = verticalScrollBar()->value() >= verticalScrollBar()->maximum();
prepareBlock().insertHtml(html);
if (atBottom) {
verticalScrollBar()->setValue(verticalScrollBar()->maximum());
scrollToBottom();
}
}
@ -169,7 +175,7 @@ void ChatView::appendHtmlServerMessage(const QString &html, bool optionalIsBold,
prepareBlock().insertHtml(htmlText);
if (atBottom) {
verticalScrollBar()->setValue(verticalScrollBar()->maximum());
scrollToBottom();
}
}
@ -215,6 +221,46 @@ void ChatView::appendUrlTag(QTextCursor &cursor, QString url)
cursor.setCharFormat(oldFormat);
}
void ChatView::appendGameLinkTag(QTextCursor &cursor, const QString &url)
{
const QUrl gameUrl(url);
const QUrlQuery query(gameUrl);
const QString hostname = query.queryItemValue("hostname");
// FullyDecoded undoes every %XX escape, so a description that itself
// contains "%" cannot end up displayed as "%25" in the label.
const QString description = query.queryItemValue("game", QUrl::FullyDecoded);
const int gameId = query.queryItemValue("gameid").toInt();
QString label;
if (gameId > 0 && !hostname.isEmpty()) {
// Links built before the description was embedded stay readable: the
// id + server fallback below is identical to the old anchor text.
if (!description.isEmpty()) {
// Multi-arg .arg() replaces all placeholders in a single pass, so a
// description containing "%…" cannot corrupt later placeholders.
label = tr("Join game \"%1\" (#%2) on %3").arg(description, QString::number(gameId), hostname);
} else {
label = tr("Join game #%1 on %2").arg(QString::number(gameId), hostname);
}
} else {
label = tr("Join game");
}
QTextCharFormat oldFormat = cursor.charFormat();
QTextCharFormat gameLinkFormat = oldFormat;
gameLinkFormat.setForeground(linkColor);
gameLinkFormat.setFontWeight(QFont::Bold);
gameLinkFormat.setAnchor(true);
gameLinkFormat.setAnchorHref(url);
QColor background = palette().highlight().color();
background.setAlpha(40);
gameLinkFormat.setBackground(background);
cursor.setCharFormat(gameLinkFormat);
cursor.insertText(label);
cursor.setCharFormat(oldFormat);
}
void ChatView::appendMessage(QString message,
RoomMessageTypeFlags messageType,
const ServerInfo_User &userInfo,
@ -227,6 +273,14 @@ void ChatView::appendMessage(QString message,
// messageType should be Event_RoomSay::UserMessage though we don't actually check
bool isUserMessage = !(userName.toLower() == "servatrice" || userName.isEmpty());
bool sameSender = isUserMessage && userName == lastSender;
if (isUserMessage) {
chatHistory.append({userName, message, QDateTime::currentDateTime()});
while (chatHistory.size() > MAX_CHAT_HISTORY) {
chatHistory.removeFirst();
}
}
QTextCursor cursor = prepareBlock(sameSender);
lastSender = userName;
@ -338,11 +392,36 @@ void ChatView::appendMessage(QString message,
}
}
if (atBottom) {
// ChatHistory messages are only ever sent once per room, right after joining, before the user can
// interact with the view. Always scroll to the bottom so the whole history is visible on join.
if (atBottom || messageType.testFlag(Event_RoomSay::ChatHistory)) {
scrollToBottom();
}
}
void ChatView::scrollToBottom()
{
// The document layout, and therefore the scrollbar range, may be updated asynchronously (e.g. while
// the chat history is loaded into a view that has not been laid out yet). Setting the value once is
// not enough: keep stickToBottom set so any later range change scrolls to the new maximum as well.
stickToBottom = true;
verticalScrollBar()->setValue(verticalScrollBar()->maximum());
}
void ChatView::onScrollBarRangeChanged()
{
if (stickToBottom) {
verticalScrollBar()->setValue(verticalScrollBar()->maximum());
}
}
void ChatView::onScrollBarValueChanged(int value)
{
if (value < verticalScrollBar()->maximum()) {
stickToBottom = false;
}
}
void ChatView::checkTag(QTextCursor &cursor, QString &message)
{
if (message.startsWith("[card]")) {
@ -474,6 +553,17 @@ void ChatView::checkWord(QTextCursor &cursor, QString &message)
}
}
if (fullWordUpToSpaceOrEnd.startsWith("cockatrice://", Qt::CaseInsensitive)) {
// Only links to a game (cockatrice://joingame) become invite buttons;
// any other cockatrice:// scheme falls through to plain text below.
const QUrl gameLink(fullWordUpToSpaceOrEnd);
if (gameLink.host().compare("joingame", Qt::CaseInsensitive) == 0) {
appendGameLinkTag(cursor, fullWordUpToSpaceOrEnd);
cursor.insertText(rest, defaultFormat);
return;
}
}
// check word mentions
for (const QString &word : highlightedWords) {
if (fullWordUpToSpaceOrEnd.compare(word, Qt::CaseInsensitive) == 0) {
@ -558,6 +648,19 @@ void ChatView::clearChat()
document()->clear();
lastSender = "";
evenNumber = true;
chatHistory.clear();
}
QString ChatView::getRecentChatLog(int maxMessages) const
{
QStringList lines;
int start = qMax(0, chatHistory.size() - maxMessages);
for (int i = start; i < chatHistory.size(); ++i) {
const ChatLogEntry &entry = chatHistory.at(i);
lines.append(
QString("[%1] %2: %3").arg(entry.timestamp.toString("hh:mm:ss")).arg(entry.userName).arg(entry.message));
}
return lines.join("\n");
}
void ChatView::redactMessages(const QString &userName, int amount)
@ -685,6 +788,11 @@ void ChatView::mouseReleaseEvent(QMouseEvent *event)
void ChatView::openLink(const QUrl &link)
{
if (link.scheme() == "cockatrice") {
emit cockatriceLinkActivated(link.toString(QUrl::FullyEncoded));
return;
}
if ((link.scheme() == "card") || (link.scheme() == "user")) {
return;
}

View file

@ -33,6 +33,13 @@ public:
QTextBlock block;
};
struct ChatLogEntry
{
QString userName;
QString message;
QDateTime timestamp;
};
class ChatView : public QTextBrowser
{
Q_OBJECT
@ -60,15 +67,20 @@ private:
QStringList highlightedWords;
bool evenNumber;
bool showTimestamps;
bool stickToBottom = false;
HoveredItemType hoveredItemType;
QString hoveredContent;
QAction *messageClicked;
QMap<QString, QVector<UserMessagePosition>> userMessagePositions;
QList<ChatLogEntry> chatHistory;
static constexpr int MAX_CHAT_HISTORY = 200;
[[nodiscard]] QTextFragment getFragmentUnderMouse(const QPoint &pos) const;
QTextCursor prepareBlock(bool same = false);
void scrollToBottom();
void appendCardTag(QTextCursor &cursor, const QString &cardName);
void appendUrlTag(QTextCursor &cursor, QString url);
void appendGameLinkTag(QTextCursor &cursor, const QString &url);
static QColor getCustomMentionColor();
static QColor getCustomHighlightColor();
void showSystemPopup(const QString &userName);
@ -88,6 +100,8 @@ private slots:
void actMessageClicked();
void adjustColorsToPalette();
void refreshBlockColors();
void onScrollBarRangeChanged();
void onScrollBarValueChanged(int value);
public:
ChatView(TabSupervisor *_tabSupervisor, AbstractGame *_game, bool _showTimestamps, QWidget *parent = nullptr);
@ -102,6 +116,7 @@ public:
bool playerBold = false);
void clearChat();
void redactMessages(const QString &userName, int amount);
QString getRecentChatLog(int maxMessages = 50) const;
protected:
void enterEvent(QEnterEvent *event) override;
@ -117,6 +132,7 @@ signals:
void addMentionTag(QString mentionTag);
void messageClickedSignal();
void showMentionPopup(const QString &userName);
void cockatriceLinkActivated(const QString &url);
};
#endif

View file

@ -19,7 +19,7 @@ struct GameFilterConfigs
bool hideNotBuddyCreatedGames = false;
bool hideOpenDecklistGames = false;
QString gameNameFilter = "";
QStringList creatorNameFilters = {};
QStringList hostNameFilters = {};
QSet<int> gameTypeFilter = {};
int maxPlayersFilterMin = DEFAULT_MAX_PLAYERS_MIN;
int maxPlayersFilterMax = DEFAULT_MAX_PLAYERS_MAX;

View file

@ -0,0 +1,23 @@
#include "game_link.h"
#include <QUrl>
#include <QUrlQuery>
QString makeGameJoinLink(const QString &hostname, int port, int roomId, int gameId, const QString &description)
{
QUrl url;
url.setScheme("cockatrice");
url.setHost("joingame");
QUrlQuery query;
query.addQueryItem("hostname", hostname);
query.addQueryItem("port", QString::number(port));
query.addQueryItem("roomid", QString::number(roomId));
query.addQueryItem("gameid", QString::number(gameId));
if (!description.isEmpty()) {
// addQueryItem percent-encodes, so arbitrary descriptions (quotes,
// ampersands, non-ASCII…) survive the trip through chat.
query.addQueryItem("game", description);
}
url.setQuery(query);
return url.toString(QUrl::FullyEncoded);
}

View file

@ -0,0 +1,41 @@
/**
* @file game_link.h
* @ingroup UI
* @brief Builds cockatrice://joingame links that let another user join a server game.
*/
#ifndef GAME_LINK_H
#define GAME_LINK_H
#include <QString>
/**
* Builds a cockatrice://joingame link for the given server game. The receiver's
* client opens it through the intent chain (connect -> join room -> join game).
* @p description, when non-empty, is embedded in the link as the URL-encoded
* "game" query item so the receiving client can name the game in its confirm
* prompt and chat anchor instead of only its numeric id. Links built without it
* stay valid: the parser and chat renderer fall back to the id alone.
*/
QString
makeGameJoinLink(const QString &hostname, int port, int roomId, int gameId, const QString &description = QString());
/**
* One game the inviter is currently in and can invite another user to.
* @p label is meant for display in menus, @p url is the ready-made invite link.
* @p description is the raw game description for building tr()-wrapped invite
* messages (the label already embeds it, but the send sites need the raw value).
* @p onlyBuddies and @p creatorName mirror the server game's room settings so
* callers can gate the invite to the creator's buddies.
*/
struct GameInviteOption
{
int gameId = 0;
QString label;
QString url;
QString description;
bool onlyBuddies = false;
QString creatorName;
};
#endif // GAME_LINK_H

View file

@ -7,6 +7,7 @@
#include "../interface/widgets/tabs/tab_room.h"
#include "../interface/widgets/tabs/tab_supervisor.h"
#include "../interface/widgets/utility/get_text_with_max.h"
#include "game_link.h"
#include "games_model.h"
#include "user/user_list_manager.h"
@ -18,8 +19,6 @@
#include <QMessageBox>
#include <QPushButton>
#include <QTreeView>
#include <QUrl>
#include <QUrlQuery>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/room_commands.pb.h>
@ -320,19 +319,12 @@ void GameSelector::customContextMenu(const QPoint &point)
dlg.exec();
});
QAction copyLink(tr("Copy Game Link"));
QAction copyLink(tr("Cop&y game link"));
connect(&copyLink, &QAction::triggered, this, [=, this]() {
const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt());
QUrl url;
url.setScheme("cockatrice");
url.setHost("joingame");
QUrlQuery query;
query.addQueryItem("hostname", client->serverName());
query.addQueryItem("port", QString::number(client->serverPort()));
query.addQueryItem("roomid", QString::number(gameInfo.room_id()));
query.addQueryItem("gameid", QString::number(gameInfo.game_id()));
url.setQuery(query);
QGuiApplication::clipboard()->setText(url.toString(QUrl::FullyEncoded));
QGuiApplication::clipboard()->setText(makeGameJoinLink(client->serverName(), client->serverPort(),
gameInfo.room_id(), gameInfo.game_id(),
QString::fromStdString(gameInfo.description())));
});
QMenu menu;
@ -367,18 +359,40 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge)
return;
}
const ServerInfo_Game &game = gameListModel->getGame(ind.data(Qt::UserRole).toInt());
joinGame(gameListModel->getGame(ind.data(Qt::UserRole).toInt()), asSpectator, asJudge);
}
void GameSelector::joinGame(const ServerInfo_Game &game, const bool asSpectator, const bool asJudge)
{
if (tabSupervisor->switchToGameTabIfAlreadyExists(game.game_id())) {
return;
}
bool spectator = asSpectator || game.player_count() == game.max_players();
bool overrideRestrictions = !tabSupervisor->getAdminLocked();
// Joining a full game without override privileges silently becomes a
// spectator join, so ask first instead of surprising the player.
const bool gameFull = game.player_count() == game.max_players();
if (gameFull && !asSpectator && !asJudge && !overrideRestrictions) {
const QMessageBox::StandardButton answer =
QMessageBox::question(this, tr("Join game"), tr("The game is full. Join as a spectator instead?"),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (answer != QMessageBox::Yes) {
return;
}
}
bool spectator = asSpectator || gameFull;
QString password;
if (game.with_password() && !(spectator && !game.spectators_need_password()) && !overrideRestrictions) {
bool ok;
password = getTextWithMax(this, tr("Join game"), tr("Password:"), QLineEdit::Password, QString(), &ok);
// Games without a description have no sensible label — fall back to the
// game id so the prompt still tells the user which game they're entering.
const QString gameLabel = QString::fromStdString(game.description());
const QString prompt = gameLabel.isEmpty() ? tr("Password for game #%1:").arg(game.game_id())
: tr("Password for \"%1\":").arg(gameLabel);
password = getTextWithMax(this, tr("Join game"), prompt, QLineEdit::Password, QString(), &ok);
if (!ok) {
return;
}
@ -404,18 +418,16 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge)
disableButtons();
}
bool GameSelector::joinGameById(int gameId)
bool GameSelector::joinGameById(const int gameId, const bool asSpectator)
{
auto *model = gameListView->model();
for (int row = 0; row < model->rowCount(); ++row) {
QModelIndex idx = model->index(row, 0);
const ServerInfo_Game &game = gameListModel->getGame(idx.data(Qt::UserRole).toInt());
if (game.game_id() == gameId) {
gameListView->setCurrentIndex(idx);
joinGame();
return true;
for (int row = 0; row < gameListModel->rowCount(); ++row) {
const ServerInfo_Game &game = gameListModel->getGame(row);
if (game.game_id() != gameId) {
continue;
}
joinGame(game, asSpectator);
return true;
}
qWarning() << "Game" << gameId << "not found";

View file

@ -171,6 +171,17 @@ private:
*/
void joinGame(bool asSpectator = false, bool asJudge = false);
/**
* @brief Performs the join or spectate action for a specific game.
* @param game The game to join.
* @param asSpectator True to join as a spectator, false to join as a player.
* @param asJudge True to join as a judge, false to join as a player.
*
* Unlike the selection-based overload, this does not depend on the game being
* visible in the filtered game list.
*/
void joinGame(const ServerInfo_Game &game, bool asSpectator = false, bool asJudge = false);
public:
/**
* @brief Constructs a GameSelector widget.
@ -202,7 +213,16 @@ public:
* @param info The ServerInfo_Game object containing information about the game to update.
*/
void processGameInfo(const ServerInfo_Game &info);
bool joinGameById(int gameId);
/**
* @brief Finds a game by ID and joins or spectates it.
* @param gameId The ID of the game to join.
* @param asSpectator True to join as a spectator, false to join as a player.
* @return True if the game was found and joined, false otherwise.
*
* Unlike the selection-based overload, this does not depend on the game
* being visible in the filtered game list.
*/
bool joinGameById(int gameId, bool asSpectator = false);
};
#endif

View file

@ -17,13 +17,27 @@ enum GameListColumn
ROOM,
CREATED,
DESCRIPTION,
CREATOR,
HOST,
GAME_TYPE,
RESTRICTIONS,
PLAYERS,
SPECTATORS
};
namespace
{
/**
* @brief Returns the user info of the game's current host, falling back to the creator.
*
* The server only sends host_info once a host transfer has happened, so older
* servers and freshly created games fall back to the original creator.
*/
const ServerInfo_User &getGameHost(const ServerInfo_Game &game)
{
return game.has_host_info() ? game.host_info() : game.creator_info();
}
} // namespace
const QString GamesModel::getGameCreatedString(const int secs)
{
static const QTime zeroTime{0, 0};
@ -110,16 +124,16 @@ QVariant GamesModel::data(const QModelIndex &index, int role) const
default:
return QVariant();
}
case CREATOR: {
case HOST: {
switch (role) {
case SORT_ROLE:
case Qt::DisplayRole:
return QString::fromStdString(gameentry.creator_info().name());
return QString::fromStdString(getGameHost(gameentry).name());
case Qt::DecorationRole: {
return UserLevelPixmapGenerator::generateIcon(
13, UserLevelFlags(gameentry.creator_info().user_level()),
gameentry.creator_info().pawn_colors(), false,
QString::fromStdString(gameentry.creator_info().privlevel()));
const ServerInfo_User &host = getGameHost(gameentry);
return UserLevelPixmapGenerator::generateIcon(13, UserLevelFlags(host.user_level()),
host.pawn_colors(), false,
QString::fromStdString(host.privlevel()));
}
default:
return QVariant();
@ -233,8 +247,8 @@ QVariant GamesModel::headerData(int section, Qt::Orientation /*orientation*/, in
}
case DESCRIPTION:
return tr("Description");
case CREATOR:
return tr("Creator");
case HOST:
return tr("Host");
case GAME_TYPE:
return tr("Type");
case RESTRICTIONS:
@ -271,6 +285,9 @@ void GamesModel::updateGameList(const ServerInfo_Game &game)
gameList.removeAt(i);
endRemoveRows();
} else {
// MergeFrom concatenates repeated fields instead of replacing them,
// so clear game_types first to avoid duplicated entries.
gameList[i].clear_game_types();
gameList[i].MergeFrom(game);
emit dataChanged(index(i, 0), index(i, NUM_COLS - 1));
}
@ -347,7 +364,7 @@ void GamesProxyModel::loadFilterParameters(const QMap<int, QString> &allGameType
gameFilters.isHideFullGames(), gameFilters.isHideGamesThatStarted(),
gameFilters.isHidePasswordProtectedGames(), gameFilters.isHideNotBuddyCreatedGames(),
gameFilters.isHideOpenDecklistGames(), gameFilters.getGameNameFilter(),
gameFilters.getCreatorNameFilters(), newGameTypeFilter, gameFilters.getMinPlayers(),
gameFilters.getHostNameFilters(), newGameTypeFilter, gameFilters.getMinPlayers(),
gameFilters.getMaxPlayers(), gameFilters.getMaxGameAge(),
gameFilters.isShowOnlyIfSpectatorsCanWatch(), gameFilters.isShowSpectatorPasswordProtected(),
gameFilters.isShowOnlyIfSpectatorsCanChat(), gameFilters.isShowOnlyIfSpectatorsCanSeeHands()});
@ -364,7 +381,7 @@ void GamesProxyModel::saveFilterParameters(const QMap<int, QString> &allGameType
gameFilters.setHideNotBuddyCreatedGames(filters.hideNotBuddyCreatedGames);
gameFilters.setHideOpenDecklistGames(filters.hideOpenDecklistGames);
gameFilters.setGameNameFilter(filters.gameNameFilter);
gameFilters.setCreatorNameFilters(filters.creatorNameFilters);
gameFilters.setHostNameFilters(filters.hostNameFilters);
QMapIterator<int, QString> gameTypeIterator(allGameTypes);
while (gameTypeIterator.hasNext()) {
@ -409,11 +426,11 @@ bool GamesProxyModel::filterAcceptsRow(int sourceRow) const
return false;
}
if (filters.hideIgnoredUserGames &&
userListProxy->isUserIgnored(QString::fromStdString(game.creator_info().name()))) {
userListProxy->isUserIgnored(QString::fromStdString(getGameHost(game).name()))) {
return false;
}
if (filters.hideNotBuddyCreatedGames &&
!userListProxy->isUserBuddy(QString::fromStdString(game.creator_info().name()))) {
!userListProxy->isUserBuddy(QString::fromStdString(getGameHost(game).name()))) {
return false;
}
if (filters.hideFullGames && game.player_count() == game.max_players()) {
@ -435,10 +452,10 @@ bool GamesProxyModel::filterAcceptsRow(int sourceRow) const
return false;
}
}
if (!filters.creatorNameFilters.isEmpty()) {
if (!filters.hostNameFilters.isEmpty()) {
bool found = false;
for (const auto &createNameFilter : filters.creatorNameFilters) {
if (QString::fromStdString(game.creator_info().name()).contains(createNameFilter, Qt::CaseInsensitive)) {
for (const auto &hostNameFilter : filters.hostNameFilters) {
if (QString::fromStdString(getGameHost(game).name()).contains(hostNameFilter, Qt::CaseInsensitive)) {
found = true;
}
}

View file

@ -39,7 +39,10 @@ void UserCardArtProvider::requestCardArt(const QString &userName, const QString
const QString key = makeKey(userName, cardName, providerId);
if (cardArtCache.contains(key) || pending.contains(key)) {
if (pending.contains(key)) {
return;
}
if (cardArtCache.contains(key) && !cardArtCache.value(key).isNull()) {
return;
}
@ -63,6 +66,10 @@ QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes)
void UserCardArtProvider::insertIntoCache(const QString &key, const QPixmap &pixmap)
{
if (pixmap.isNull()) {
return;
}
if (!cardArtCache.contains(key)) {
cacheInsertionOrder.append(key);
while (cacheInsertionOrder.size() > MaxCacheEntries) {
@ -129,8 +136,6 @@ void UserCardArtProvider::processQueue()
if (!fullRes.isNull()) {
self->insertIntoCache(key, self->cropCardArt(fullRes));
} else {
self->insertIntoCache(key, QPixmap());
}
self->pending.remove(key);

View file

@ -26,7 +26,7 @@ public slots:
private:
bool dbReady = false;
static constexpr int MaxCacheEntries = 300;
static constexpr int MaxCacheEntries = 1024;
QList<QString> cacheInsertionOrder; // FIFO eviction
QMap<QString, QPixmap> cardArtCache;
QSet<QString> pending;

View file

@ -1,7 +1,8 @@
#include "user_card_settings_dialog.h"
#include "../../../card_picture_loader/card_picture_loader.h"
#include "card/card_completer_proxy_model.h"
#include "../../cards/art_crop_attribution.h"
#include "../../utility/completer_utils.h"
#include "card/card_search_model.h"
#include "card_database_display_model.h"
#include "card_database_model.h"
@ -14,19 +15,46 @@
#include <QFormLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QLabel>
#include <QLineEdit>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPushButton>
#include <QRegularExpression>
#include <QVBoxLayout>
#include <QWheelEvent>
#include <cmath>
#include <libcockatrice/card/database/card_database_manager.h>
namespace
{
// Gesture clamps and step sizes for this direct manipulation surface. The
// gesture zoom floor is 1.0: basescale already cover fits the art, so any
// smaller scale would underfill the strip.
constexpr qreal kMinGestureZoom = 1.0;
constexpr qreal kMaxZoom = 4.0;
constexpr qreal kKeyPanOffsetStep = 0.01;
constexpr qreal kKeyZoomStep = 1.05;
constexpr qreal kWheelZoomBase = 1.15; // zoom factor per wheel notch
} // namespace
CardArtPreviewWidget::CardArtPreviewWidget(QWidget *parent) : QWidget(parent)
{
setMinimumSize(400, 72);
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
setFocusPolicy(Qt::StrongFocus);
setAccessibleName(tr("Banner preview"));
setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
}
void CardArtPreviewWidget::focusInEvent(QFocusEvent *event)
{
// Snapshot for the Esc or Backspace reset, restoring whatever the user
// had when the surface took focus
paramsAtFocusIn = params;
QWidget::focusInEvent(event);
}
void CardArtPreviewWidget::setPixmap(const QPixmap &pixmap)
@ -38,6 +66,13 @@ void CardArtPreviewWidget::setPixmap(const QPixmap &pixmap)
void CardArtPreviewWidget::setParams(const CardArtParams &p)
{
params = p;
setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
update();
}
void CardArtPreviewWidget::setAttribution(const QString &attribution)
{
attributionText = attribution;
update();
}
@ -60,9 +95,22 @@ void CardArtPreviewWidget::paintEvent(QPaintEvent *)
painter.setBrush(accentColor);
painter.drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2);
// Visible keyboard focus per the focus cursor contract, Tab must show
// where the keys land, including in the empty state
const auto paintFocusRing = [&painter, &cardRect, this]() {
if (!hasFocus()) {
return;
}
QPen focusPen(palette().color(QPalette::Highlight), 2);
painter.setPen(focusPen);
painter.setBrush(Qt::NoBrush);
painter.drawRoundedRect(cardRect.adjusted(-1, -1, 1, 1), 6, 6);
};
if (sourcePixmap.isNull()) {
painter.setPen(QColor(150, 150, 150));
painter.drawText(rect, Qt::AlignCenter, tr("No card selected"));
paintFocusRing();
return;
}
@ -73,7 +121,7 @@ void CardArtPreviewWidget::paintEvent(QPaintEvent *)
&sourcePixmap // direct pixmap
);
// Avatar placeholder so the left-margin interaction is visible
// Avatar placeholder so the left margin interaction is visible
const int avatarX = rect.left() + 14;
const int avatarY = rect.top() + (rect.height() - 36) / 2;
const QRect avatarRect(avatarX, avatarY, 36, 36);
@ -90,24 +138,202 @@ void CardArtPreviewWidget::paintEvent(QPaintEvent *)
painter.setPen(QPen(QColor(70, 80, 95), 2));
painter.setBrush(Qt::NoBrush);
painter.drawEllipse(avatarRect.adjusted(-1, -1, 1, 1));
paintArtAttribution(painter, cardRect, attributionText);
paintFocusRing();
}
qreal CardArtPreviewWidget::bannerTravel() const
{
if (sourcePixmap.isNull()) {
return 0.0;
}
// Mirror UserListPainter::drawCardArt() exactly: same strip metrics, the
// copy is drawn 1:1, so output pixels equal widget pixels here.
const int cardH = rect().height() - 4;
const int totalW = (rect().right() - 4) - rect().left();
const int marginL = qRound(totalW * params.marginPctL);
const int marginR = qRound(totalW * params.marginPctR);
const int drawW = totalW - marginL - marginR;
const double basescale = qMax(double(drawW) / sourcePixmap.width(), double(cardH) / sourcePixmap.height());
// qRound for literal parity with drawCardArt, which rounds the scaled
// height before computing travel
const double scaledH = qRound(sourcePixmap.height() * basescale * params.zoom);
return scaledH - cardH;
}
void CardArtPreviewWidget::applyCropDelta(qreal dOffset, qreal zoomFactor)
{
CardArtParams next = params;
next.verticalOffset = qBound(0.0, params.verticalOffset + dOffset, 1.0);
next.zoom = qBound(kMinGestureZoom, params.zoom * zoomFactor, kMaxZoom);
if (sameCrop(next, params)) {
return;
}
params = next;
setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
update();
emit paramsEdited(params);
}
bool CardArtPreviewWidget::sameCrop(const CardArtParams &a, const CardArtParams &b) const
{
// Exact comparison on purpose: clamped assignments yield identical bits,
// while qFuzzyCompare based equality misbehaves around zero
return a.verticalOffset == b.verticalOffset && a.zoom == b.zoom;
}
void CardArtPreviewWidget::restoreSnapshot()
{
// The snapshot only ever holds values that passed the gesture clamps,
// so it is safe to restore verbatim
if (sameCrop(paramsAtFocusIn, params)) {
return;
}
params = paramsAtFocusIn;
setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2)));
update();
emit paramsEdited(params);
}
void CardArtPreviewWidget::mousePressEvent(QMouseEvent *event)
{
if (event->button() != Qt::LeftButton || sourcePixmap.isNull() || bannerTravel() <= 0.5) {
QWidget::mousePressEvent(event);
return;
}
dragging = true;
lastDragPos = event->pos();
setCursor(Qt::ClosedHandCursor);
event->accept();
}
void CardArtPreviewWidget::mouseMoveEvent(QMouseEvent *event)
{
if (!dragging || sourcePixmap.isNull()) {
QWidget::mouseMoveEvent(event);
return;
}
const qreal dy = event->pos().y() - lastDragPos.y();
lastDragPos = event->pos();
const qreal travel = bannerTravel();
if (travel <= 0.5) {
event->accept();
return;
}
// Dragging moves the ART with the cursor, so the crop window slides the
// other way through the available travel.
applyCropDelta(-dy / travel, 1.0);
event->accept();
}
void CardArtPreviewWidget::mouseReleaseEvent(QMouseEvent *event)
{
if (event->button() == Qt::LeftButton) {
dragging = false;
unsetCursor();
event->accept();
return;
}
QWidget::mouseReleaseEvent(event);
}
void CardArtPreviewWidget::wheelEvent(QWheelEvent *event)
{
if (sourcePixmap.isNull()) {
QWidget::wheelEvent(event);
return;
}
const qreal notches = static_cast<qreal>(event->angleDelta().y()) / 120.0;
if (notches == 0.0) {
event->accept();
return;
}
applyCropDelta(0.0, std::pow(kWheelZoomBase, notches));
event->accept();
}
void CardArtPreviewWidget::keyPressEvent(QKeyEvent *event)
{
if (sourcePixmap.isNull()) {
QWidget::keyPressEvent(event);
return;
}
switch (event->key()) {
case Qt::Key_Escape:
if (sameCrop(params, paramsAtFocusIn)) {
// Nothing to undo on this surface, let the event reach the
// dialog so Esc keeps its close meaning there
QWidget::keyPressEvent(event);
return;
}
restoreSnapshot();
break;
case Qt::Key_Backspace:
restoreSnapshot();
break;
case Qt::Key_Up:
applyCropDelta(-kKeyPanOffsetStep, 1.0);
break;
case Qt::Key_Down:
applyCropDelta(kKeyPanOffsetStep, 1.0);
break;
case Qt::Key_Plus:
case Qt::Key_Equal:
applyCropDelta(0.0, kKeyZoomStep);
break;
case Qt::Key_Minus:
applyCropDelta(0.0, 1.0 / kKeyZoomStep);
break;
default:
QWidget::keyPressEvent(event);
return;
}
event->accept();
}
UserCardArtSettingsDialog::UserCardArtSettingsDialog(const CardArtParams &initial, QWidget *parent)
: QDialog(parent), currentParams(initial)
{
setWindowTitle(tr("Card Art Settings"));
// Legacy stored banners may carry zoom below the gesture floor or an out
// of range offset. Normalize once on open so the preview renders filled
// and Ok saves a state the gestures can reach again
currentParams.zoom = qBound(kMinGestureZoom, currentParams.zoom, kMaxZoom);
currentParams.verticalOffset = qBound(0.0, currentParams.verticalOffset, 1.0);
setMinimumWidth(500);
setupUi();
// Seed UI from initial params
if (!initial.cardName.isEmpty()) {
searchBar->setText(initial.cardName);
onCardNameChanged(initial.cardName);
if (!currentParams.cardName.isEmpty()) {
// onCardNameChanged overwrites cardProviderId with the first printing,
// so remember the stored one before it runs
const QString storedProviderId = currentParams.cardProviderId;
searchBar->setText(currentParams.cardName);
onCardNameChanged(currentParams.cardName);
// onCardNameChanged leaves the printing combo on the first printing in
// the database, which would silently change the stored banner card on
// accept. Restore the stored printing when it resolves locally.
const int storedPrintingIndex = providerComboBox->findData(storedProviderId);
if (storedPrintingIndex != -1) {
providerComboBox->setCurrentIndex(storedPrintingIndex);
} else if (!storedProviderId.isEmpty()) {
// Stored printing not in the local database: keep it rather than
// silently substituting the first printing.
currentParams.cardProviderId = storedProviderId;
reloadPreview();
}
}
marginLSpin->setValue(initial.marginPctL);
marginRSpin->setValue(initial.marginPctR);
verticalOffsetSpin->setValue(initial.verticalOffset);
zoomSpin->setValue(initial.zoom);
marginLSpin->setValue(currentParams.marginPctL);
marginRSpin->setValue(currentParams.marginPctR);
}
CardArtParams UserCardArtSettingsDialog::params() const
@ -128,34 +354,18 @@ QDoubleSpinBox *UserCardArtSettingsDialog::makeSpinBox(double min, double max, d
void UserCardArtSettingsDialog::initializeSearchBar()
{
searchBar = new QLineEdit;
searchBar->setPlaceholderText(tr("Type a card name..."));
cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this);
cardDatabaseDisplayModel = new CardDatabaseDisplayModel(this);
cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel);
searchModel = new CardSearchModel(cardDatabaseDisplayModel, this);
proxyModel = new CardCompleterProxyModel(this);
proxyModel->setSourceModel(searchModel);
proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
proxyModel->setFilterRole(Qt::DisplayRole);
completer = new QCompleter(proxyModel, this);
completer->setCompletionRole(Qt::DisplayRole);
completer->setCompletionMode(QCompleter::PopupCompletion);
completer->setCaseSensitivity(Qt::CaseInsensitive);
completer->setFilterMode(Qt::MatchContains);
completer->setMaxVisibleItems(15);
const CardCompleterSetup cardSetup = createCardCompleter(cardDatabaseDisplayModel, this, 15);
searchModel = cardSetup.searchModel;
proxyModel = cardSetup.proxyModel;
completer = cardSetup.completer;
searchBar->setCompleter(completer);
connect(searchBar, &QLineEdit::textEdited, searchModel, &CardSearchModel::updateSearchResults);
connect(searchBar, &QLineEdit::textEdited, this, [this](const QString &text) {
const QString pattern = ".*" + QRegularExpression::escape(text) + ".*";
proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption));
if (!text.isEmpty()) {
completer->complete();
}
});
connectCardCompleterSearch(searchBar, cardSetup);
connect(completer, static_cast<void (QCompleter::*)(const QString &)>(&QCompleter::activated), this,
[this](const QString &completion) {
@ -183,29 +393,33 @@ void UserCardArtSettingsDialog::setupUi()
marginLSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctL, 0.01);
marginRSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctR, 0.01);
verticalOffsetSpin = makeSpinBox(0.0, 1.0, currentParams.verticalOffset, 0.01);
zoomSpin = makeSpinBox(0.1, 4.0, currentParams.zoom, 0.05);
auto *form = new QFormLayout;
form->addRow(tr("Card name:"), searchBar);
form->addRow(tr("Card ProviderId:"), providerComboBox);
form->addRow(tr("Left margin (%):"), marginLSpin);
form->addRow(tr("Right margin (%):"), marginRSpin);
form->addRow(tr("Vertical offset:"), verticalOffsetSpin);
form->addRow(tr("Zoom:"), zoomSpin);
cardNameLabel = new QLabel;
printingLabel = new QLabel;
marginLLabel = new QLabel;
marginRLabel = new QLabel;
form->addRow(cardNameLabel, searchBar);
form->addRow(printingLabel, providerComboBox);
form->addRow(marginLLabel, marginLSpin);
form->addRow(marginRLabel, marginRSpin);
auto *controlsGroup = new QGroupBox(tr("Parameters"));
controlsGroup = new QGroupBox;
controlsGroup->setLayout(form);
preview = new CardArtPreviewWidget;
auto *previewLayout = new QVBoxLayout;
previewLayout->addWidget(preview);
auto *previewGroup = new QGroupBox(tr("Preview"));
previewCaptionLabel = new QLabel;
previewCaptionLabel->setAlignment(Qt::AlignCenter);
previewCaptionLabel->setWordWrap(true);
previewLayout->addWidget(previewCaptionLabel);
previewGroup = new QGroupBox;
previewGroup->setLayout(previewLayout);
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
auto *removeBtn = new QPushButton(tr("Remove Banner Card"));
removeBtn = new QPushButton;
buttons->addButton(removeBtn, QDialogButtonBox::ResetRole);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
@ -215,16 +429,38 @@ void UserCardArtSettingsDialog::setupUi()
accept();
});
// The banner leads visually, card selection and margins support it below.
auto *root = new QVBoxLayout;
root->addWidget(controlsGroup);
root->addWidget(previewGroup);
root->addWidget(controlsGroup);
root->addWidget(buttons);
setLayout(root);
connect(marginLSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged);
connect(marginRSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged);
connect(verticalOffsetSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged);
connect(zoomSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged);
// Gestures are the only editors of offset and zoom on this surface.
// Margins stay explicit numeric controls: they trim the strip's
// sides and have no natural drag mapping.
connect(preview, &CardArtPreviewWidget::paramsEdited, this,
[this](const CardArtParams &edited) { currentParams = edited; });
retranslateUi();
}
void UserCardArtSettingsDialog::retranslateUi()
{
setWindowTitle(tr("Card Art Settings"));
searchBar->setPlaceholderText(tr("Type a card name..."));
cardNameLabel->setText(tr("Card name:"));
printingLabel->setText(tr("Printing:"));
marginLLabel->setText(tr("Left margin (%):"));
marginRLabel->setText(tr("Right margin (%):"));
controlsGroup->setTitle(tr("Card"));
previewCaptionLabel->setText(
tr("Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores."));
previewGroup->setTitle(tr("Banner"));
removeBtn->setText(tr("Remove Banner Card"));
}
void UserCardArtSettingsDialog::populateProviderCombo(const QString &cardName)
@ -274,7 +510,7 @@ void UserCardArtSettingsDialog::onCardNameChanged(const QString &name)
populateProviderCombo(name);
if (providerComboBox->count() == 0) {
// No printings found for this card; nothing to preview.
// No printings found for this card, nothing to preview.
currentPixmap = QPixmap();
preview->setPixmap(currentPixmap);
currentParams.cardProviderId.clear();
@ -304,7 +540,7 @@ void UserCardArtSettingsDialog::reloadPreview()
// whichever CardInfo we just asked for, so the preview catches up once
// the image actually arrives instead of staying on the placeholder.
//
// Disconnect any previous listener first -- otherwise switching cards
// Disconnect any previous listener first, otherwise switching cards
// repeatedly stacks up connections to old CardInfo objects, each of
// which would still fire reloadPreview() (harmlessly, but wastefully)
// whenever ITS art finishes loading later.
@ -314,8 +550,8 @@ void UserCardArtSettingsDialog::reloadPreview()
CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040));
if (fullRes.isNull()) {
// Not loaded yet -- wait for the signal instead of giving up.
// card.getCardPtr() is a CardInfoPtr (QSharedPointer<CardInfo>);
// Not loaded yet, wait for the signal instead of giving up.
// card.getCardPtr() is a CardInfoPtr (QSharedPointer<CardInfo>),
// .data() gives the raw QObject* needed for connect().
CardInfo *cardInfo = card.getCardPtr().data();
if (cardInfo) {
@ -327,13 +563,16 @@ void UserCardArtSettingsDialog::reloadPreview()
currentPixmap = UserCardArtProvider::cropCardArt(fullRes);
preview->setPixmap(currentPixmap);
preview->setParams(currentParams);
// Only attribute the art once the new pixmap is actually displayed, so a
// cache miss (which keeps the previous pixmap on screen) doesn't pair the
// new card's attribution with the old card's art.
preview->setAttribution(buildArtAttribution(card));
}
void UserCardArtSettingsDialog::onParamChanged()
{
currentParams.marginPctL = marginLSpin->value();
currentParams.marginPctR = marginRSpin->value();
currentParams.verticalOffset = verticalOffsetSpin->value();
currentParams.zoom = zoomSpin->value();
preview->setParams(currentParams);
}
}

View file

@ -8,13 +8,29 @@
#include <QPixmap>
class QCompleter;
class QFocusEvent;
class QGroupBox;
class QKeyEvent;
class QMouseEvent;
class QLineEdit;
class QDoubleSpinBox;
class QLabel;
class QPushButton;
class QWheelEvent;
class CardDatabaseModel;
class CardDatabaseDisplayModel;
class CardSearchModel;
class CardCompleterProxyModel;
/**
* @brief Interactive preview of the user list banner art.
*
* Renders the banner strip with the given CardArtParams through the same
* UserListPainter::drawCardArt() the live delegate uses, including the
* avatar placeholder and fade masks. Dragging pans the art vertically at
* output scale, the wheel zooms, arrow keys nudge, Backspace or Esc
* restores the parameters as of focus gain.
*/
class CardArtPreviewWidget : public QWidget
{
Q_OBJECT
@ -24,13 +40,33 @@ public:
void setPixmap(const QPixmap &pixmap);
void setParams(const CardArtParams &params);
void setAttribution(const QString &attribution);
signals:
/** @brief Emitted whenever direct manipulation (drag, wheel, keys) changes the parameters. */
void paramsEdited(const CardArtParams &params);
protected:
void paintEvent(QPaintEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
void focusInEvent(QFocusEvent *event) override;
private:
qreal bannerTravel() const; ///< vertical travel of the art behind the strip, in output pixels
void applyCropDelta(qreal dOffset, qreal zoomFactor);
bool sameCrop(const CardArtParams &a, const CardArtParams &b) const;
void restoreSnapshot();
QPixmap sourcePixmap;
CardArtParams params;
CardArtParams paramsAtFocusIn; ///< crop as of the latest focus gain, restored by Esc or Backspace
QString attributionText;
QPoint lastDragPos; ///< widget space position of the previous mouse move while panning
bool dragging{false}; ///< true between an accepted press and its release, guards stale drag positions
};
class UserCardArtSettingsDialog : public QDialog
@ -49,6 +85,7 @@ private slots:
private:
void setupUi();
void retranslateUi();
void populateProviderCombo(const QString &cardName);
void initializeSearchBar();
QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step);
@ -64,14 +101,20 @@ private:
QMetaObject::Connection pixmapUpdatedConnection;
QLabel *cardNameLabel;
QLabel *printingLabel;
QLabel *marginLLabel;
QLabel *marginRLabel;
QGroupBox *controlsGroup;
QLabel *previewCaptionLabel;
QGroupBox *previewGroup;
QPushButton *removeBtn;
QDoubleSpinBox *marginLSpin;
QDoubleSpinBox *marginRSpin;
QDoubleSpinBox *verticalOffsetSpin;
QDoubleSpinBox *zoomSpin;
CardArtPreviewWidget *preview;
QPixmap currentPixmap;
CardArtParams currentParams;
};
#endif // COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H
#endif // COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H

View file

@ -1,5 +1,6 @@
#include "user_context_menu.h"
#include "../../dialogs/dlg_report_user.h"
#include "../../interface/widgets/tabs/tab_account.h"
#include "../../interface/widgets/tabs/tab_game.h"
#include "../../interface/widgets/tabs/tab_supervisor.h"
@ -41,6 +42,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent,
aAddToIgnoreList = new QAction(QString(), this);
aRemoveFromIgnoreList = new QAction(QString(), this);
aKick = new QAction(QString(), this);
aReport = new QAction(QString(), this);
aWarnUser = new QAction(QString(), this);
aWarnHistory = new QAction(QString(), this);
aBan = new QAction(QString(), this);
@ -50,6 +52,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent,
aPromoteToJudge = new QAction(QString(), this);
aDemoteFromJudge = new QAction(QString(), this);
aGetAdminNotes = new QAction(QString(), this);
aInvestigateUser = new QAction(QString(), this);
retranslateUi();
}
@ -64,6 +67,7 @@ void UserContextMenu::retranslateUi()
aAddToIgnoreList->setText(tr("Add to &ignore list"));
aRemoveFromIgnoreList->setText(tr("Remove from &ignore list"));
aKick->setText(tr("Kick from &game"));
aReport->setText(tr("Report user"));
aWarnUser->setText(tr("Warn user"));
aWarnHistory->setText(tr("View user's war&n history"));
aBan->setText(tr("Ban from &server"));
@ -73,6 +77,7 @@ void UserContextMenu::retranslateUi()
aPromoteToJudge->setText(tr("Promote user to &judge"));
aDemoteFromJudge->setText(tr("Demote user from judge"));
aGetAdminNotes->setText(tr("View admin notes"));
aInvestigateUser->setText(tr("Investigate user"));
}
void UserContextMenu::gamesOfUserReceived(const Response &resp, const CommandContainer &commandContainer)
@ -144,7 +149,8 @@ void UserContextMenu::warnUser_processGetWarningsListResponse(const Response &r)
if (response.warning_size() > 0) {
for (int i = 0; i < response.warning_size(); ++i) {
dlg->addWarningOption(QString::fromStdString(response.warning(i)).simplified());
int startingIl = i < response.warning_il_size() ? response.warning_il(i) : 1;
dlg->addWarningOption(QString::fromStdString(response.warning(i)).simplified(), startingIl);
}
}
dlg->show();
@ -355,6 +361,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
{
QAction *aCopyToClipBoard = nullptr, *aRemoveMessages = nullptr;
aUserName->setText(userName);
const bool anotherUser = userName != userListProxy->getOwnUsername();
auto *menu = new QMenu(static_cast<QWidget *>(parent()));
menu->addAction(aUserName);
@ -366,6 +373,17 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
menu->addAction(aDetails);
menu->addAction(aShowGames);
menu->addAction(aChat);
const QList<GameInviteOption> inviteOptions = inviteOptionsForUser(userName);
if (!inviteOptions.isEmpty()) {
auto *inviteMenu = new QMenu(tr("&Invite to Game"), menu);
for (const GameInviteOption &option : inviteOptions) {
QAction *inviteAction = inviteMenu->addAction(option.label);
inviteAction->setEnabled(anotherUser && online);
connect(inviteAction, &QAction::triggered, this,
[this, userName, option] { execInvite(userName, option); });
}
menu->addMenu(inviteMenu);
}
if (userLevel.testFlag(ServerInfo_User::IsRegistered) && userListProxy->isOwnUserRegistered()) {
menu->addSeparator();
if (userListProxy->isUserBuddy(userName)) {
@ -383,6 +401,9 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
aRemoveMessages = new QAction(tr("Remove this user's messages"), this);
menu->addAction(aRemoveMessages);
}
if (userListProxy->isOwnUserRegistered()) {
menu->addAction(aReport);
}
if (game && (game->isHost() || !tabSupervisor->getAdminLocked())) {
menu->addSeparator();
menu->addAction(aKick);
@ -396,6 +417,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
menu->addAction(aBanHistory);
menu->addSeparator();
menu->addAction(aGetAdminNotes);
menu->addAction(aInvestigateUser);
menu->addSeparator();
if (userLevel.testFlag(ServerInfo_User::IsModerator) &&
@ -416,10 +438,10 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
menu->addAction(aPromoteToJudge);
}
}
bool anotherUser = userName != userListProxy->getOwnUsername();
aDetails->setEnabled(true);
aChat->setEnabled(anotherUser && online);
aShowGames->setEnabled(online);
aReport->setEnabled(anotherUser);
aAddToBuddyList->setEnabled(anotherUser);
aRemoveFromBuddyList->setEnabled(anotherUser);
aAddToIgnoreList->setEnabled(anotherUser);
@ -430,6 +452,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
aBan->setEnabled(anotherUser);
aBanHistory->setEnabled(anotherUser);
aGetAdminNotes->setEnabled(anotherUser);
aInvestigateUser->setEnabled(anotherUser);
aPromoteToMod->setEnabled(anotherUser);
aDemoteFromMod->setEnabled(anotherUser);
@ -451,6 +474,15 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
execRemoveFromIgnore(userName);
} else if (actionClicked == aKick) {
execKick(playerId);
} else if (actionClicked == aReport) {
int gameId = game ? game->getGameMetaInfo()->gameId() : -1;
QString autoChatLog;
if (chatView) {
autoChatLog = chatView->getRecentChatLog(50);
}
auto dlgReport = new DlgReportUser(client, userName, gameId, autoChatLog, static_cast<QWidget *>(parent()));
dlgReport->setAttribute(Qt::WA_DeleteOnClose);
dlgReport->exec();
} else if (actionClicked == aBan) {
execBan(userName);
} else if (actionClicked == aPromoteToMod || actionClicked == aDemoteFromMod) {
@ -465,6 +497,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
execWarnHistory(userName);
} else if (actionClicked == aGetAdminNotes) {
execAdminNotes(userName);
} else if (actionClicked == aInvestigateUser) {
execInvestigateUser(userName);
} else if (actionClicked == aCopyToClipBoard) {
QClipboard *clipboard = QGuiApplication::clipboard();
clipboard->setText(deckHash);
@ -480,6 +514,60 @@ void UserContextMenu::execChat(const QString &userName)
emit openMessageDialog(userName, true);
}
QList<GameInviteOption> UserContextMenu::inviteOptionsForUser(const QString &userName) const
{
if (!gameInviteLinkProvider) {
return {};
}
const QList<GameInviteOption> options = gameInviteLinkProvider();
QList<GameInviteOption> result;
for (const GameInviteOption &option : options) {
// Buddy-only games accept invites only from their creator, and only to
// users on the creator's buddy list.
if (option.onlyBuddies &&
(option.creatorName != userListProxy->getOwnUsername() || !userListProxy->isUserBuddy(userName))) {
continue;
}
result.append(option);
}
return result;
}
void UserContextMenu::execInvite(const QString &userName)
{
const QList<GameInviteOption> options = inviteOptionsForUser(userName);
if (options.isEmpty()) {
return;
}
if (options.size() == 1) {
execInvite(userName, options.first());
return;
}
// More than one game in the room — let the user pick which one to invite to.
auto *menu = new QMenu(static_cast<QWidget *>(parent()));
for (const GameInviteOption &option : options) {
QAction *action = menu->addAction(option.label);
connect(action, &QAction::triggered, this, [this, userName, option] { execInvite(userName, option); });
}
menu->setAttribute(Qt::WA_DeleteOnClose);
menu->popup(QCursor::pos());
}
void UserContextMenu::execInvite(const QString &userName, const GameInviteOption &option)
{
// Name the game by description first, then its id — "Join my game 'Magic'
// (#123)" — so a description-less fallback still identifies the game.
// The multi-arg .arg() overloads replace in a single pass, so a description
// containing "%…" cannot corrupt later placeholders.
const QString prefix =
option.description.isEmpty()
? tr("Join my game (#%1):").arg(option.gameId)
: tr("Join my game \"%1\" (#%2):").arg(option.description, QString::number(option.gameId));
tabSupervisor->sendInviteToUser(userName, prefix + " " + option.url);
}
void UserContextMenu::execDetails(const QString &userName)
{
auto *w = new UserInfoBox(client, false, static_cast<QWidget *>(parent()),
@ -587,6 +675,11 @@ void UserContextMenu::execAdminNotes(const QString &userName)
client->sendCommand(pend);
}
void UserContextMenu::execInvestigateUser(const QString &userName)
{
tabSupervisor->openTabModeration(userName);
}
void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod)
{
Command_AdjustMod cmd;

View file

@ -7,9 +7,12 @@
#ifndef USER_CONTEXT_MENU_H
#define USER_CONTEXT_MENU_H
#include <QObject>
#include <libcockatrice/network/server/remote/user_level.h>
#include "../../interface/widgets/server/game_link.h"
#include <QList>
#include <QObject>
#include <functional>
#include <libcockatrice/network/server/remote/user_level.h>
class AbstractGame;
class UserListProxy;
class AbstractClient;
@ -38,11 +41,14 @@ private:
QAction *aAddToBuddyList, *aRemoveFromBuddyList;
QAction *aAddToIgnoreList, *aRemoveFromIgnoreList;
QAction *aKick;
QAction *aReport;
QAction *aBan, *aBanHistory;
QAction *aPromoteToMod, *aDemoteFromMod;
QAction *aPromoteToJudge, *aDemoteFromJudge;
QAction *aWarnUser, *aWarnHistory;
QAction *aGetAdminNotes;
std::function<QList<GameInviteOption>()> gameInviteLinkProvider;
QAction *aInvestigateUser;
signals:
void openMessageDialog(const QString &userName, bool focus);
private slots:
@ -80,9 +86,28 @@ public:
return userListProxy;
}
void setGameInviteLinkProvider(std::function<QList<GameInviteOption>()> provider)
{
gameInviteLinkProvider = std::move(provider);
}
/**
* The games currently inviteable for @p userName, honoring the room's
* buddy-only setting (the inviter must be the game's creator and the
* target a buddy of theirs). Empty when there is no live provider.
*/
QList<GameInviteOption> inviteOptionsForUser(const QString &userName) const;
/** Whether at least one invite link is currently available for @p userName. */
bool hasGameInviteLink(const QString &userName) const
{
return !inviteOptionsForUser(userName).isEmpty();
}
// Individual action entry points — used by UserInfoPopup to trigger
// actions without re-running the full context menu flow.
void execChat(const QString &userName);
void execInvite(const QString &userName);
void execDetails(const QString &userName);
void execShowGames(const QString &userName);
void execAddToBuddy(const QString &userName);
@ -95,8 +120,12 @@ public:
void execBanHistory(const QString &userName);
void execWarnHistory(const QString &userName);
void execAdminNotes(const QString &userName);
void execInvestigateUser(const QString &userName);
void execAdjustMod(const QString &userName, bool shouldBeMod);
void execAdjustJudge(const QString &userName, bool shouldBeJudge);
private:
void execInvite(const QString &userName, const GameInviteOption &option);
};
#endif

View file

@ -319,6 +319,7 @@ void UserInfoBox::actBannerCard()
if (hasUserInfo && currentUserInfo.has_card_art_params()) {
const auto &cap = currentUserInfo.card_art_params();
initial.cardName = QString::fromStdString(cap.card_name());
initial.cardProviderId = QString::fromStdString(cap.card_provider_id());
initial.marginPctL = cap.margin_pct_l();
initial.marginPctR = cap.margin_pct_r();
initial.verticalOffset = cap.vertical_offset();

View file

@ -1,6 +1,8 @@
#include "user_info_popup.h"
#include "../../cards/art_crop_attribution.h"
#include "../../interface/pixel_map_generator.h"
#include "../../interface/theme_manager.h"
#include "../../interface/widgets/tabs/tab_supervisor.h"
#include "user_list_painter.h"
@ -17,11 +19,48 @@
#include <QStandardItem>
#include <QStyledItemDelegate>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/commands.pb.h>
#include <libcockatrice/protocol/pb/response_get_games_of_user.pb.h>
#include <libcockatrice/protocol/pending_command.h>
/// Qt stylesheets accept #aarrggbb, which is QColor::name(QColor::HexArgb).
static QString colorStr(const QColor &color)
{
return color.name(QColor::HexArgb);
}
PopupTheme PopupTheme::fromPalette(const QPalette &palette, bool dark)
{
PopupTheme t;
t.dark = dark;
const QColor window = palette.color(QPalette::Window);
const QColor base = palette.color(QPalette::Base);
const QColor mid = palette.color(QPalette::Mid);
const QColor text = palette.color(QPalette::Text);
const QColor disabledText = palette.color(QPalette::Disabled, QPalette::Text);
const QColor highlight = palette.color(QPalette::Highlight);
t.bg = window;
t.border = mid;
t.text = text;
t.subText = disabledText;
t.statusText = disabledText;
t.buttonBg = base;
t.buttonBorder = mid;
t.buttonHover = UserListPainter::blend(base, highlight, dark ? 0.30 : 0.12);
t.buttonPressed = UserListPainter::blend(base, highlight, dark ? 0.50 : 0.25);
t.buttonDisabled = disabledText;
t.closeBg = UserListPainter::blend(base, window, 0.5);
t.closeHover = dark ? QColor(200, 50, 50) : UserListPainter::blend(QColor(200, 50, 50), base, 0.45);
t.gamesRow = base;
t.gamesSelected = UserListPainter::blend(base, highlight, dark ? 0.45 : 0.30);
t.gamesSeparator = mid;
t.gamesSeparator.setAlpha(90);
return t;
}
// ── Compact game row delegate ─────────────────────────────────────────────────
class PopupGameDelegate : public QStyledItemDelegate
@ -48,8 +87,14 @@ public:
const QRect rect = option.rect;
const ServerInfo_Game game = var.value<ServerInfo_Game>();
const bool selected = option.state & QStyle::State_Selected;
const bool dark = themeManager && themeManager->isDarkModeActive();
// The widget palette can be stale after a runtime theme change, so the
// rows are styled from the application palette (always current).
const QPalette pal = qApp->palette();
const QColor base = pal.color(QPalette::Base);
const QColor highlight = pal.color(QPalette::Highlight);
p->fillRect(rect, selected ? QColor(35, 45, 62) : QColor(14, 18, 26));
p->fillRect(rect, selected ? UserListPainter::blend(base, highlight, dark ? 0.45 : 0.30) : base);
// State colour dot
const QColor dot = game.started() ? QColor(239, 68, 68)
@ -64,7 +109,7 @@ public:
QFont tf = option.font;
tf.setBold(true);
p->setFont(tf);
p->setPen(QColor(205, 215, 230));
p->setPen(pal.color(QPalette::Text));
const int textX = rect.left() + 26;
const int countW = 52;
const int titleW = rect.width() - textX - countW - 6;
@ -74,13 +119,15 @@ public:
// Player count
const bool full = game.player_count() >= game.max_players();
p->setFont(option.font);
p->setPen(full ? QColor(249, 115, 22) : QColor(110, 128, 150));
p->setPen(full ? QColor(249, 115, 22) : pal.color(QPalette::Disabled, QPalette::Text));
p->drawText(QRect(rect.right() - countW - 4, rect.top(), countW, rect.height()),
Qt::AlignVCenter | Qt::AlignRight,
QStringLiteral("%1/%2").arg(game.player_count()).arg(game.max_players()));
// Row separator
p->setPen(QColor(24, 32, 44));
QColor separator = pal.color(QPalette::Mid);
separator.setAlpha(90);
p->setPen(separator);
p->drawLine(rect.bottomLeft(), rect.bottomRight());
p->restore();
@ -95,17 +142,27 @@ UserInfoHeaderWidget::UserInfoHeaderWidget(QWidget *parent) : QWidget(parent)
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
}
void UserInfoHeaderWidget::setUserData(const ServerInfo_User &user,
bool online,
const QPixmap &avatar,
const QPixmap &cardArt,
const CardArtParams &params)
void UserInfoHeaderWidget::setUserData(const ServerInfo_User &_user,
bool _online,
const QPixmap &_avatar,
const QPixmap &_cardArt,
const CardArtParams &_params)
{
m_user = user;
m_online = online;
m_avatar = avatar;
m_cardArt = cardArt;
m_params = params;
user = _user;
online = _online;
avatar = _avatar;
cardArt = _cardArt;
params = _params;
attribution.clear();
if (user.has_card_art_params()) {
const ExactCard card =
CardDatabaseManager::query()->getCard({QString::fromStdString(user.card_art_params().card_name()),
QString::fromStdString(user.card_art_params().card_provider_id())});
if (card) {
attribution = buildArtAttribution(card);
}
}
update();
}
@ -115,31 +172,48 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *)
p.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing);
const QRect rect = this->rect();
const UserLevelFlags level(m_user.user_level());
const QString userName = QString::fromStdString(m_user.name());
const QString privLevel = QString::fromStdString(m_user.privlevel());
const UserLevelFlags level(user.user_level());
const QString userName = QString::fromStdString(user.name());
const QString privLevel = QString::fromStdString(user.privlevel());
// Dark base
p.fillRect(rect, QColor(14, 18, 26));
const bool dark = themeManager && themeManager->isDarkModeActive();
const UserListPainter::Style style = UserListPainter::resolveStyle(qApp->palette(), dark);
// Palette surface
{
QLinearGradient bg(0, 0, rect.width(), 0);
bg.setColorAt(0, style.cardStart);
bg.setColorAt(1, style.cardEnd);
p.fillRect(rect, bg);
}
// ── Card art background ───────────────────────────────────────────────────
if (!m_cardArt.isNull()) {
if (!cardArt.isNull()) {
// Same DPR normalization as UserListPainter::drawCardArt: the cache
// carries screen scaled pixmaps on HiDPI displays, the math below is
// in raw pixels.
QPixmap art = cardArt;
art.setDevicePixelRatio(1.0);
const int w = rect.width();
const int h = rect.height();
const int mL = qRound(w * m_params.marginPctL);
const int mR = qRound(w * m_params.marginPctR);
const int mL = qRound(w * params.marginPctL);
const int mR = qRound(w * params.marginPctR);
const int dW = w - mL - mR;
const double base = qMax(double(dW) / m_cardArt.width(), double(h) / m_cardArt.height());
const double scale = base * m_params.zoom;
const int sW = qRound(m_cardArt.width() * scale);
const int sH = qRound(m_cardArt.height() * scale);
const double base = qMax(double(dW) / art.width(), double(h) / art.height());
const double scale = base * params.zoom;
const int sW = qRound(art.width() * scale);
const int sH = qRound(art.height() * scale);
const QPixmap scaled = m_cardArt.scaled(sW, sH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
const int srcX = (sW - dW) / 2;
const int srcY = qBound(0, qRound((sH - h) * m_params.verticalOffset), qMax(0, sH - h));
const QPixmap scaled = art.scaled(sW, sH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
// Clamp against stored zoom < 1, which can push srcX negative and silently
// underfill the strip with transparent padding
const int safeSrcX = qBound(0, (sW - dW) / 2, qMax(0, sW - dW));
const int safeSrcY = qBound(0, qRound((sH - h) * params.verticalOffset), qMax(0, sH - h));
QImage img = scaled.copy(srcX, srcY, dW, h).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
QImage img =
scaled.copy(safeSrcX, safeSrcY, dW, h).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
{
QPainter mask(&img);
mask.setCompositionMode(QPainter::CompositionMode_DestinationIn);
@ -155,12 +229,14 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *)
p.setOpacity(1.0);
}
// Bottom gradient overlay so avatar and text are always legible
// Bottom gradient overlay so avatar and text are always legible. The scrim
// is the palette's Window color so it reads naturally in either scheme.
{
const QColor scrim = qApp->palette().color(QPalette::Window);
QLinearGradient ov(0, 0, 0, rect.height());
ov.setColorAt(0.0, QColor(14, 18, 26, 0));
ov.setColorAt(0.55, QColor(14, 18, 26, 110));
ov.setColorAt(1.0, QColor(14, 18, 26, 230));
ov.setColorAt(0.0, QColor(scrim.red(), scrim.green(), scrim.blue(), 0));
ov.setColorAt(0.55, QColor(scrim.red(), scrim.green(), scrim.blue(), 110));
ov.setColorAt(1.0, QColor(scrim.red(), scrim.green(), scrim.blue(), 230));
p.fillRect(rect, ov);
}
@ -187,20 +263,20 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *)
p.save();
p.setClipPath(clip);
if (!m_avatar.isNull()) {
p.drawPixmap(ar, m_avatar.scaled(ar.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation));
if (!avatar.isNull()) {
p.drawPixmap(ar, avatar.scaled(ar.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation));
} else {
p.setPen(Qt::NoPen);
p.setBrush(accent.darker(200));
p.setBrush(UserListPainter::blend(accent, style.base, dark ? 0.45 : 0.72));
p.drawEllipse(ar);
const QPixmap pawn =
UserLevelPixmapGenerator::generatePixmap(AvatarPawnSize, level, m_user.pawn_colors(), false, privLevel);
UserLevelPixmapGenerator::generatePixmap(AvatarPawnSize, level, user.pawn_colors(), false, privLevel);
p.drawPixmap(ar.center().x() - AvatarPawnSize / 2, ar.center().y() - AvatarPawnSize / 2, pawn);
}
p.restore();
// Status ring
p.setPen(QPen(m_online ? QColor(34, 197, 94) : QColor(70, 80, 95), 2.5));
p.setPen(QPen(online ? QColor(34, 197, 94) : style.ringOffline, 2.5));
p.setBrush(Qt::NoBrush);
p.drawEllipse(QRectF(ar).adjusted(-1.25, -1.25, 1.25, 1.25));
@ -212,7 +288,7 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *)
nf.setBold(true);
nf.setPointSizeF(nf.pointSizeF() * 1.12);
p.setFont(nf);
p.setPen(m_online ? QColor(220, 228, 240) : QColor(90, 100, 115));
p.setPen(online ? style.textOnline : style.textOffline);
p.drawText(QRect(tx, ay, tw, AvatarSize / 2 + 4), Qt::AlignBottom | Qt::AlignLeft,
QFontMetrics(nf).elidedText(userName, Qt::ElideRight, tw));
@ -243,143 +319,184 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *)
const int bw = bfm.horizontalAdvance(badge.text) + 10;
const QRect br(tx, ay + AvatarSize / 2 + 6, bw, 15);
p.setPen(Qt::NoPen);
p.setBrush(badge.color.darker(160));
p.setBrush(UserListPainter::blend(badge.color, style.base, dark ? 0.55 : 0.78));
p.drawRoundedRect(br, 3, 3);
p.setPen(badge.color.lighter(150));
p.setPen(dark ? UserListPainter::blend(badge.color, Qt::white, 0.5)
: UserListPainter::blend(badge.color, Qt::black, 0.35));
p.drawText(br, Qt::AlignCenter, badge.text);
}
// The painter font at this point depends on whether a badge was drawn
// (badge font vs username font), so pin an explicit font for the pill.
p.setFont(font());
// Only show the attribution when there is actually art on screen: on a
// cache miss cardArt is null and the pill would float over the plain
// header with no art behind it.
if (!cardArt.isNull()) {
paintArtAttribution(p, rect, attribution, Qt::AlignRight | Qt::AlignBottom, 0.8);
}
}
// ── UserInfoPopup ─────────────────────────────────────────────────────────────
UserInfoPopup::UserInfoPopup(TabSupervisor *ts,
AbstractClient *client,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap,
UserInfoPopup::UserInfoPopup(TabSupervisor *_ts,
AbstractClient *_client,
const QMap<QString, QPixmap> *_avatarCache,
const QMap<QString, QPixmap> *_cardArtCache,
const QMap<QString, CardArtParams> *_cardArtParamsMap,
QWidget *parent)
: QFrame(parent, Qt::Tool | Qt::FramelessWindowHint), m_ts(ts), m_client(client), m_avatarCache(avatarCache),
m_cardArtCache(cardArtCache), m_cardArtParamsMap(cardArtParamsMap)
: QFrame(parent, Qt::Tool | Qt::FramelessWindowHint), ts(_ts), client(_client), avatarCache(_avatarCache),
cardArtCache(_cardArtCache), cardArtParamsMap(_cardArtParamsMap)
{
setAttribute(Qt::WA_ShowWithoutActivating);
setFixedWidth(PopupWidth);
setFrameShape(QFrame::NoFrame);
buildUi();
// Restyle the popup chrome when the theme or its color scheme changes.
if (themeManager) {
connect(themeManager, &ThemeManager::themeChanged, this, &UserInfoPopup::applyTheme);
}
}
void UserInfoPopup::buildUi()
{
setStyleSheet(QStringLiteral("UserInfoPopup {"
" background:#0e1218;"
" border:1px solid #1e2838;"
" border-radius:8px;"
"}"));
auto *root = new QVBoxLayout(this);
root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0);
// Header
m_header = new UserInfoHeaderWidget(this);
root->addWidget(m_header);
header = new UserInfoHeaderWidget(this);
root->addWidget(header);
// Action area — rebuilt per user
m_actionArea = new QWidget(this);
m_actionArea->setStyleSheet(QStringLiteral("background:#0e1218;"));
root->addWidget(m_actionArea);
// Action area, rebuilt per user
actionArea = new QWidget(this);
root->addWidget(actionArea);
// Thin separator
auto *sep = new QFrame(this);
sep->setFrameShape(QFrame::HLine);
sep->setStyleSheet(QStringLiteral("color:#1a2434; margin: 0 8px;"));
root->addWidget(sep);
separator = new QFrame(this);
separator->setFrameShape(QFrame::HLine);
root->addWidget(separator);
// Games header row
auto *gh = new QHBoxLayout;
gh->setContentsMargins(10, 4, 8, 2);
auto *gl = new QLabel(tr("Games"), this);
gl->setStyleSheet(QStringLiteral("color:#6882a0; font-size:11px; font-weight:bold; background:transparent;"));
gh->addWidget(gl);
gamesLabel = new QLabel(tr("Games"), this);
gh->addWidget(gamesLabel);
gh->addStretch();
m_refreshBtn = new QPushButton(QStringLiteral("↻"), this);
m_refreshBtn->setFixedSize(20, 20);
m_refreshBtn->setFlat(true);
m_refreshBtn->setStyleSheet(
QStringLiteral("QPushButton{color:#6882a0;border:none;font-size:14px;background:transparent;}"
"QPushButton:hover{color:white;}"));
connect(m_refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames);
gh->addWidget(m_refreshBtn);
refreshBtn = new QPushButton(QStringLiteral("↻"), this);
refreshBtn->setFixedSize(20, 20);
refreshBtn->setFlat(true);
connect(refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames);
gh->addWidget(refreshBtn);
root->addLayout(gh);
// Status label
m_gamesStatus = new QLabel(this);
m_gamesStatus->setAlignment(Qt::AlignCenter);
m_gamesStatus->setStyleSheet(
QStringLiteral("color:#3a4a5e; font-size:11px; padding:10px; background:transparent;"));
root->addWidget(m_gamesStatus);
gamesStatus = new QLabel(this);
gamesStatus->setAlignment(Qt::AlignCenter);
root->addWidget(gamesStatus);
// Games list
m_gamesModel = new QStandardItemModel(this);
m_gamesView = new QListView(this);
m_gamesView->setModel(m_gamesModel);
m_gamesView->setItemDelegate(new PopupGameDelegate(m_gamesView));
m_gamesView->setFrameShape(QFrame::NoFrame);
m_gamesView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_gamesView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
m_gamesView->setMaximumHeight(220);
m_gamesView->setStyleSheet(QStringLiteral("QListView{background:#0e1218;border:none;}"
"QListView::item:selected{background:#232e42;}"));
m_gamesView->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_gamesView, &QListView::customContextMenuRequested, this, &UserInfoPopup::onGamesContextMenu);
gamesModel = new QStandardItemModel(this);
gamesView = new QListView(this);
gamesView->setModel(gamesModel);
gamesView->setItemDelegate(new PopupGameDelegate(gamesView));
gamesView->setFrameShape(QFrame::NoFrame);
gamesView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
gamesView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
gamesView->setMaximumHeight(220);
gamesView->setContextMenuPolicy(Qt::CustomContextMenu);
connect(gamesView, &QListView::customContextMenuRequested, this, &UserInfoPopup::onGamesContextMenu);
root->addWidget(m_gamesView);
root->addWidget(gamesView);
// Close button — positioned absolutely in the top-right corner
m_closeBtn = new QPushButton(QStringLiteral("✕"), this);
m_closeBtn->setFixedSize(22, 22);
m_closeBtn->setFlat(true);
m_closeBtn->setStyleSheet(QStringLiteral("QPushButton{background:rgba(14,18,26,180);color:#607080;"
"border:none;border-radius:11px;font-size:10px;}"
"QPushButton:hover{color:white;background:rgba(200,50,50,200);}"));
connect(m_closeBtn, &QPushButton::clicked, this, &UserInfoPopup::closeRequested);
// Close button, positioned absolutely in the top right corner
closeBtn = new QPushButton(QStringLiteral("✕"), this);
closeBtn->setFixedSize(22, 22);
closeBtn->setFlat(true);
connect(closeBtn, &QPushButton::clicked, this, &UserInfoPopup::closeRequested);
applyTheme();
}
void UserInfoPopup::applyTheme()
{
const bool dark = themeManager && themeManager->isDarkModeActive();
theme = PopupTheme::fromPalette(qApp->palette(), dark);
setStyleSheet(QStringLiteral("UserInfoPopup {"
" background:%1;"
" border:1px solid %2;"
" border-radius:8px;"
"}")
.arg(colorStr(theme.bg), colorStr(theme.border)));
actionArea->setStyleSheet(QStringLiteral("background:%1;").arg(colorStr(theme.bg)));
separator->setStyleSheet(QStringLiteral("color:%1; margin: 0 8px;").arg(colorStr(theme.border)));
gamesLabel->setStyleSheet(QStringLiteral("color:%1; font-size:11px; font-weight:bold; background:transparent;")
.arg(colorStr(theme.subText)));
refreshBtn->setStyleSheet(QStringLiteral("QPushButton{color:%1;border:none;font-size:14px;background:transparent;}"
"QPushButton:hover{color:%2;}")
.arg(colorStr(theme.subText), colorStr(theme.text)));
gamesStatus->setStyleSheet(QStringLiteral("color:%1; font-size:11px; padding:10px; background:transparent;")
.arg(colorStr(theme.statusText)));
gamesView->setStyleSheet(QStringLiteral("QListView{background:%1;border:none;}"
"QListView::item:selected{background:%2;}")
.arg(colorStr(theme.gamesRow), colorStr(theme.gamesSelected)));
closeBtn->setStyleSheet(
QStringLiteral("QPushButton{background:%1;color:%2;"
"border:none;border-radius:11px;font-size:10px;}"
"QPushButton:hover{color:%3;background:%4;}")
.arg(colorStr(theme.closeBg), colorStr(theme.subText), colorStr(theme.text), colorStr(theme.closeHover)));
header->update();
}
// ── Action button factory ─────────────────────────────────────────────────────
static QPushButton *makeBtn(const QString &label, const QString &tip, QWidget *p)
static QPushButton *makeBtn(const QString &label, const QString &tip, QWidget *p, const PopupTheme &t)
{
auto *b = new QPushButton(label, p);
b->setToolTip(tip);
b->setFixedHeight(26);
b->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
b->setStyleSheet(QStringLiteral("QPushButton{"
" background:#192030;color:#b8c8de;border:1px solid #263040;"
" background:%1;color:%2;border:1px solid %3;"
" border-radius:4px;font-size:11px;padding:0 4px;"
"}"
"QPushButton:hover{background:#223050;color:white;}"
"QPushButton:pressed{background:#162030;}"
"QPushButton:disabled{color:#384858;border-color:#192030;}"));
"QPushButton:hover{background:%4;color:%5;}"
"QPushButton:pressed{background:%6;}"
"QPushButton:disabled{color:%7;border-color:%3;}")
.arg(colorStr(t.buttonBg), colorStr(t.text), colorStr(t.buttonBorder), colorStr(t.buttonHover),
colorStr(t.text), colorStr(t.buttonPressed), colorStr(t.buttonDisabled)));
return b;
}
void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored)
{
// Clear previous contents
delete m_actionArea->layout();
const auto old = m_actionArea->findChildren<QPushButton *>(QString{}, Qt::FindDirectChildrenOnly);
delete actionArea->layout();
const auto old = actionArea->findChildren<QPushButton *>(QString{}, Qt::FindDirectChildrenOnly);
for (auto *w : old) {
w->deleteLater();
}
const QString name = QString::fromStdString(userInfo.name());
const auto ownLevel = UserLevelFlags(m_ts->getUserInfo()->user_level());
const bool isSelf = (name == QString::fromStdString(m_ts->getUserInfo()->name()));
const auto ownLevel = UserLevelFlags(ts->getUserInfo()->user_level());
const bool isSelf = (name == QString::fromStdString(ts->getUserInfo()->name()));
const bool isMod = ownLevel.testFlag(ServerInfo_User::IsModerator);
const bool isAdmin = ownLevel.testFlag(ServerInfo_User::IsAdmin);
const auto their = UserLevelFlags(userInfo.user_level());
const bool isReg = their.testFlag(ServerInfo_User::IsRegistered);
auto *grid = new QGridLayout(m_actionArea);
auto *grid = new QGridLayout(actionArea);
grid->setContentsMargins(8, 6, 8, 6);
grid->setSpacing(4);
@ -394,16 +511,16 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o
};
// ── Always visible ────────────────────────────────────────────────────────
auto *chat = makeBtn(tr("Chat"), tr("Open private chat"), m_actionArea);
auto *chat = makeBtn(tr("Chat"), tr("Open private chat"), actionArea, theme);
chat->setEnabled(!isSelf && online);
connect(chat, &QPushButton::clicked, this, [this, name] { emit chatRequested(name); });
add(chat);
auto *prof = makeBtn(tr("Profile"), tr("View user profile"), m_actionArea);
auto *prof = makeBtn(tr("Profile"), tr("View user profile"), actionArea, theme);
connect(prof, &QPushButton::clicked, this, [this, name] { emit detailsRequested(name); });
add(prof);
auto *games = makeBtn(tr("Games"), tr("Show this user's games"), m_actionArea);
auto *games = makeBtn(tr("Games"), tr("Show this user's games"), actionArea, theme);
games->setEnabled(!isSelf && online);
connect(games, &QPushButton::clicked, this, [this, name] { emit showGamesRequested(name); });
add(games);
@ -411,20 +528,20 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o
// ── Buddy / ignore (registered users only) ────────────────────────────────
if (!isSelf && isReg) {
if (isBuddy) {
auto *b = makeBtn(tr("− Buddy"), tr("Remove from buddy list"), m_actionArea);
auto *b = makeBtn(tr("− Buddy"), tr("Remove from buddy list"), actionArea, theme);
connect(b, &QPushButton::clicked, this, [this, name] { emit removeBuddyRequested(name); });
add(b);
} else {
auto *b = makeBtn(tr("+ Buddy"), tr("Add to buddy list"), m_actionArea);
auto *b = makeBtn(tr("+ Buddy"), tr("Add to buddy list"), actionArea, theme);
connect(b, &QPushButton::clicked, this, [this, name] { emit addBuddyRequested(name); });
add(b);
}
if (isIgnored) {
auto *b = makeBtn(tr("− Ignore"), tr("Remove from ignore list"), m_actionArea);
auto *b = makeBtn(tr("− Ignore"), tr("Remove from ignore list"), actionArea, theme);
connect(b, &QPushButton::clicked, this, [this, name] { emit removeIgnoreRequested(name); });
add(b);
} else {
auto *b = makeBtn(tr("+ Ignore"), tr("Add to ignore list"), m_actionArea);
auto *b = makeBtn(tr("+ Ignore"), tr("Add to ignore list"), actionArea, theme);
connect(b, &QPushButton::clicked, this, [this, name] { emit addIgnoreRequested(name); });
add(b);
}
@ -437,10 +554,10 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o
col = 0;
} // start mod section on a fresh row
auto *ban = makeBtn(tr("Ban"), tr("Ban from server"), m_actionArea);
auto *warn = makeBtn(tr("Warn"), tr("Warn user"), m_actionArea);
auto *bLog = makeBtn(tr("Ban log"), tr("View ban history"), m_actionArea);
auto *wLog = makeBtn(tr("Warn log"), tr("View warning history"), m_actionArea);
auto *ban = makeBtn(tr("Ban"), tr("Ban from server"), actionArea, theme);
auto *warn = makeBtn(tr("Warn"), tr("Warn user"), actionArea, theme);
auto *bLog = makeBtn(tr("Ban log"), tr("View ban history"), actionArea, theme);
auto *wLog = makeBtn(tr("Warn log"), tr("View warning history"), actionArea, theme);
connect(ban, &QPushButton::clicked, this, [this, name] { emit banRequested(name); });
connect(warn, &QPushButton::clicked, this, [this, name] { emit warnRequested(name); });
connect(bLog, &QPushButton::clicked, this, [this, name] { emit banHistoryRequested(name); });
@ -453,31 +570,31 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o
// ── Admin actions ─────────────────────────────────────────────────────────
if (!isSelf && isAdmin) {
auto *notes = makeBtn(tr("Notes"), tr("View admin notes"), m_actionArea);
auto *notes = makeBtn(tr("Notes"), tr("View admin notes"), actionArea, theme);
connect(notes, &QPushButton::clicked, this, [this, name] { emit adminNotesRequested(name); });
add(notes);
if (their.testFlag(ServerInfo_User::IsModerator)) {
auto *b = makeBtn(tr("− Mod"), tr("Demote from moderator"), m_actionArea);
auto *b = makeBtn(tr("− Mod"), tr("Demote from moderator"), actionArea, theme);
connect(b, &QPushButton::clicked, this, [this, name] { emit demoteFromModRequested(name); });
add(b);
} else if (isReg) {
auto *b = makeBtn(tr("+ Mod"), tr("Promote to moderator"), m_actionArea);
auto *b = makeBtn(tr("+ Mod"), tr("Promote to moderator"), actionArea, theme);
connect(b, &QPushButton::clicked, this, [this, name] { emit promoteToModRequested(name); });
add(b);
}
if (their.testFlag(ServerInfo_User::IsJudge)) {
auto *b = makeBtn(tr("− Judge"), tr("Demote from judge"), m_actionArea);
auto *b = makeBtn(tr("− Judge"), tr("Demote from judge"), actionArea, theme);
connect(b, &QPushButton::clicked, this, [this, name] { emit demoteFromJudgeRequested(name); });
add(b);
} else if (isReg) {
auto *b = makeBtn(tr("+ Judge"), tr("Promote to judge"), m_actionArea);
auto *b = makeBtn(tr("+ Judge"), tr("Promote to judge"), actionArea, theme);
connect(b, &QPushButton::clicked, this, [this, name] { emit promoteToJudgeRequested(name); });
add(b);
}
}
m_actionArea->adjustSize();
actionArea->adjustSize();
}
void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored)
@ -488,7 +605,7 @@ void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool on
void UserInfoPopup::onGamesContextMenu(const QPoint &pos)
{
const QModelIndex idx = m_gamesView->indexAt(pos);
const QModelIndex idx = gamesView->indexAt(pos);
if (!idx.isValid()) {
return;
}
@ -501,8 +618,9 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos)
QMenu menu(this);
menu.setStyleSheet(
QStringLiteral("QMenu{background:#12182a;color:#c8d8ec;border:1px solid #1e2838;border-radius:4px;}"
"QMenu::item:selected{background:#223050;}"));
QStringLiteral("QMenu{background:%1;color:%2;border:1px solid %3;border-radius:4px;}"
"QMenu::item:selected{background:%4;}")
.arg(colorStr(theme.bg), colorStr(theme.text), colorStr(theme.border), colorStr(theme.buttonHover)));
const bool canJoin = !game.started() && game.player_count() < game.max_players();
QAction *join = menu.addAction(tr("Join game"));
@ -513,7 +631,7 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos)
spec = menu.addAction(tr("Spectate"));
}
const QAction *chosen = menu.exec(m_gamesView->viewport()->mapToGlobal(pos));
const QAction *chosen = menu.exec(gamesView->viewport()->mapToGlobal(pos));
if (!chosen) {
return;
}
@ -527,37 +645,46 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos)
// ── showForUser ───────────────────────────────────────────────────────────────
void UserInfoPopup::refreshHeader()
{
if (currentUser.isEmpty()) {
return;
}
const QPixmap avatar = avatarCache ? avatarCache->value(currentUser) : QPixmap{};
const CardArtParams params = (cardArtParamsMap && cardArtParamsMap->contains(currentUser))
? cardArtParamsMap->value(currentUser)
: CardArtParams{};
const QString artKey = currentUser + u'|' + params.cardName + u'|' + params.cardProviderId;
const QPixmap cardArt = (cardArtCache && !params.cardName.isEmpty()) ? cardArtCache->value(artKey) : QPixmap{};
header->setUserData(currentUserInfo, currentOnline, avatar, cardArt, params);
}
void UserInfoPopup::showForUser(const QString &userName,
const ServerInfo_User &userInfo,
bool online,
bool isBuddy,
bool isIgnored)
{
m_currentUser = userName;
m_currentUserInfo = userInfo;
m_currentOnline = online;
currentUser = userName;
currentUserInfo = userInfo;
currentOnline = online;
// Header
const QPixmap avatar = m_avatarCache ? m_avatarCache->value(userName) : QPixmap{};
const CardArtParams params = (m_cardArtParamsMap && m_cardArtParamsMap->contains(userName))
? m_cardArtParamsMap->value(userName)
: CardArtParams{};
const QString artKey = userName + u'|' + params.cardName + u'|' + params.cardProviderId;
const QPixmap cardArt = (m_cardArtCache && !params.cardName.isEmpty()) ? m_cardArtCache->value(artKey) : QPixmap{};
m_header->setUserData(userInfo, online, avatar, cardArt, params);
refreshHeader();
// Actions
rebuildActionButtons(userInfo, online, isBuddy, isIgnored);
// Games list reset
m_gamesModel->clear();
m_gamesView->hide();
m_gamesStatus->setText(tr("Loading games…"));
m_gamesStatus->show();
gamesModel->clear();
gamesView->hide();
gamesStatus->setText(tr("Loading games…"));
gamesStatus->show();
// Close button — top-right corner, above everything
m_closeBtn->move(PopupWidth - m_closeBtn->width() - 6, 6);
m_closeBtn->raise();
// Close button, top right corner, above everything
closeBtn->move(PopupWidth - closeBtn->width() - 6, 6);
closeBtn->raise();
adjustSize();
fetchGames();
@ -567,40 +694,40 @@ void UserInfoPopup::showForUser(const QString &userName,
void UserInfoPopup::fetchGames()
{
if (!m_client || m_currentUser.isEmpty()) {
if (!client || currentUser.isEmpty()) {
return;
}
Command_GetGamesOfUser cmd;
cmd.set_user_name(m_currentUser.toStdString());
cmd.set_user_name(currentUser.toStdString());
const QString snapshot = m_currentUser;
PendingCommand *pend = m_client->prepareSessionCommand(cmd);
const QString snapshot = currentUser;
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this,
[this, snapshot](const Response &r) { onGamesReceived(r, snapshot); });
m_client->sendCommand(pend);
client->sendCommand(pend);
}
void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser)
{
if (forUser != m_currentUser) {
return; // stale response — different user showing now
if (forUser != currentUser) {
return; // stale response, different user showing now
}
m_gamesModel->clear();
gamesModel->clear();
if (r.response_code() != Response::RespOk) {
m_gamesStatus->setText(tr("Could not load games."));
m_gamesStatus->show();
m_gamesView->hide();
gamesStatus->setText(tr("Could not load games."));
gamesStatus->show();
gamesView->hide();
return;
}
const auto &resp = r.GetExtension(Response_GetGamesOfUser::ext);
if (resp.game_list_size() == 0) {
m_gamesStatus->setText(tr("No active games."));
m_gamesStatus->show();
m_gamesView->hide();
gamesStatus->setText(tr("No active games."));
gamesStatus->show();
gamesView->hide();
return;
}
@ -608,29 +735,29 @@ void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser)
auto *item = new QStandardItem;
item->setData(QVariant::fromValue(resp.game_list(i)), PopupRoles::GameData);
item->setEditable(false);
m_gamesModel->appendRow(item);
gamesModel->appendRow(item);
}
m_gamesStatus->hide();
m_gamesView->show();
gamesStatus->hide();
gamesView->show();
// Fit exactly to the number of visible rows, scroll when more than 5
constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint
constexpr int maxRows = 5;
const int count = m_gamesModel->rowCount();
const int count = gamesModel->rowCount();
const int visible = qMin(count, maxRows);
m_gamesView->setFixedHeight(visible * rowH + 2);
m_gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff);
gamesView->setFixedHeight(visible * rowH + 2);
gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff);
adjustSize();
}
void UserInfoPopup::refreshGames()
{
m_gamesModel->clear();
m_gamesView->hide();
m_gamesStatus->setText(tr("Loading games…"));
m_gamesStatus->show();
gamesModel->clear();
gamesView->hide();
gamesStatus->setText(tr("Loading games…"));
gamesStatus->show();
fetchGames();
}
@ -646,4 +773,4 @@ void UserInfoPopup::leaveEvent(QEvent *e)
{
QFrame::leaveEvent(e);
emit mouseLeftPopup();
}
}

View file

@ -26,6 +26,35 @@ namespace PopupRoles
constexpr int GameData = Qt::UserRole + 10;
}
// Popup theme
/**
* Palette-derived colors for the popup chrome. Both color schemes read from
* the active QPalette so custom palettes are respected. @c dark only tunes the
* blend strengths.
*/
struct PopupTheme
{
bool dark = false;
QColor bg;
QColor border;
QColor text;
QColor subText;
QColor buttonBg;
QColor buttonBorder;
QColor buttonHover;
QColor buttonPressed;
QColor buttonDisabled;
QColor closeBg;
QColor closeHover;
QColor gamesRow;
QColor gamesSelected;
QColor gamesSeparator;
QColor statusText;
static PopupTheme fromPalette(const QPalette &palette, bool dark);
};
// ── Header widget ─────────────────────────────────────────────────────────────
/**
@ -51,21 +80,22 @@ class UserInfoHeaderWidget : public QWidget
public:
explicit UserInfoHeaderWidget(QWidget *parent = nullptr);
void setUserData(const ServerInfo_User &user,
bool online,
const QPixmap &avatar,
const QPixmap &cardArt,
const CardArtParams &params);
void setUserData(const ServerInfo_User &_user,
bool _online,
const QPixmap &_avatar,
const QPixmap &_cardArt,
const CardArtParams &_params);
protected:
void paintEvent(QPaintEvent *e) override;
private:
ServerInfo_User m_user;
bool m_online = false;
QPixmap m_avatar;
QPixmap m_cardArt;
CardArtParams m_params;
ServerInfo_User user;
bool online = false;
QPixmap avatar;
QPixmap cardArt;
CardArtParams params;
QString attribution;
};
// ── Main popup ────────────────────────────────────────────────────────────────
@ -93,11 +123,11 @@ class UserInfoPopup : public QFrame
static constexpr int PopupWidth = 316;
public:
explicit UserInfoPopup(TabSupervisor *tabSupervisor,
AbstractClient *client,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap,
explicit UserInfoPopup(TabSupervisor *_ts,
AbstractClient *_client,
const QMap<QString, QPixmap> *_avatarCache,
const QMap<QString, QPixmap> *_cardArtCache,
const QMap<QString, CardArtParams> *_cardArtParamsMap,
QWidget *parent);
/**
@ -108,14 +138,17 @@ public:
showForUser(const QString &userName, const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored);
void fetchGames();
[[nodiscard]] QString currentUser() const
[[nodiscard]] QString getCurrentUser() const
{
return m_currentUser;
return currentUser;
}
/** Called when buddy/ignore status changes externally while popup is open. */
void updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored);
/** Re-pulls the avatar/card art for the currently shown user (e.g. after it loads). */
void refreshHeader();
signals:
void mouseEnteredPopup();
void mouseLeftPopup();
@ -153,25 +186,30 @@ private slots:
private:
void buildUi();
void applyTheme();
void rebuildActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored);
TabSupervisor *m_ts;
AbstractClient *m_client;
const QMap<QString, QPixmap> *m_avatarCache;
const QMap<QString, QPixmap> *m_cardArtCache;
const QMap<QString, CardArtParams> *m_cardArtParamsMap;
TabSupervisor *ts;
AbstractClient *client;
const QMap<QString, QPixmap> *avatarCache;
const QMap<QString, QPixmap> *cardArtCache;
const QMap<QString, CardArtParams> *cardArtParamsMap;
QString m_currentUser;
ServerInfo_User m_currentUserInfo;
bool m_currentOnline = false;
PopupTheme theme;
UserInfoHeaderWidget *m_header;
QWidget *m_actionArea; ///< rebuilt per user
QListView *m_gamesView;
QStandardItemModel *m_gamesModel;
QLabel *m_gamesStatus;
QPushButton *m_closeBtn;
QPushButton *m_refreshBtn;
QString currentUser;
ServerInfo_User currentUserInfo;
bool currentOnline = false;
UserInfoHeaderWidget *header;
QWidget *actionArea; ///< rebuilt per user
QLabel *gamesLabel;
QFrame *separator;
QListView *gamesView;
QStandardItemModel *gamesModel;
QLabel *gamesStatus;
QPushButton *closeBtn;
QPushButton *refreshBtn;
};
#endif // COCKATRICE_USER_INFO_POPUP_H

View file

@ -3,6 +3,7 @@
#include "../../interface/pixel_map_generator.h"
#include <QAbstractScrollArea>
#include <QApplication>
#include <QPainter>
#include <QPainterPath>
#include <QScrollBar>
@ -19,6 +20,29 @@ QSize UserListPainter::sizeHint()
return QSize(0, RowHeight);
}
UserListPainter::Style UserListPainter::resolveStyle(const QPalette &palette, bool dark)
{
Style style;
style.dark = dark;
const QColor base = palette.color(QPalette::Base);
const QColor alt = palette.color(QPalette::AlternateBase);
style.cardStart = base;
style.cardEnd = (alt != base) ? alt : palette.color(QPalette::Midlight);
style.base = base;
style.textOnline = palette.color(QPalette::Text);
style.textOffline = palette.color(QPalette::Disabled, QPalette::Text);
style.ringOffline = palette.color(QPalette::Disabled, QPalette::Text);
style.dropShadow = dark;
return style;
}
QColor UserListPainter::blend(const QColor &a, const QColor &b, qreal t)
{
const qreal u = 1.0 - t;
return QColor(qRound(a.red() * u + b.red() * t), qRound(a.green() * u + b.green() * t),
qRound(a.blue() * u + b.blue() * t), qRound(a.alpha() * u + b.alpha() * t));
}
QColor UserListPainter::getAccentColor(const UserLevelFlags &userLevel, bool online)
{
QColor accentColor;
@ -59,16 +83,37 @@ int UserListPainter::getCardRight(const QStyleOptionViewItem &option, const QRec
void UserListPainter::drawBackground(QPainter *painter,
const QRectF &cardRect,
const QColor &accentColor,
bool selected)
bool selected,
const Style &style,
bool hasRole)
{
QLinearGradient bg(cardRect.topLeft(), cardRect.topRight());
bg.setColorAt(0, selected ? accentColor.darker(130) : accentColor.darker(320));
bg.setColorAt(1, selected ? QColor(40, 48, 60) : QColor(18, 22, 30));
if (style.dark) {
// Dark mode darkens the role color to fit the dark surface and fades
// it into the deep navy surface on the right. The text drop shadow
// keeps the username legible over the colored edge.
bg.setColorAt(0, selected ? accentColor.darker(130) : accentColor.darker(320));
bg.setColorAt(1, selected ? QColor(40, 48, 60) : QColor(18, 22, 30));
} else if (hasRole) {
// Light mode pegs the role color on the left at near full strength
// and fades it into the white surface on the right. The tint stays
// bright enough that the dark text remains legible without a shadow.
bg.setColorAt(0, blend(style.cardStart, accentColor, selected ? 0.75 : 0.65));
bg.setColorAt(1, blend(style.cardEnd, accentColor, selected ? 0.18 : 0.10));
} else {
// Regular users keep a scaled-down accent tint so the banner card art
// stays legible over a colored backdrop (the pre-branch painter was
// always dark-styled) while the role hierarchy still reads.
bg.setColorAt(0, blend(style.cardStart, accentColor, (selected ? 0.75 : 0.65) * 0.7));
bg.setColorAt(1, blend(style.cardEnd, accentColor, (selected ? 0.18 : 0.10) * 0.7));
}
painter->setPen(Qt::NoPen);
painter->setBrush(bg);
painter->drawRoundedRect(cardRect, 6, 6);
// The 3px accent bar anchors every row so the banner card art reads as a
// consistent strip in either scheme (pre-branch parity).
painter->setBrush(accentColor);
painter->drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2);
}
@ -108,6 +153,12 @@ void UserListPainter::drawCardArt(QPainter *painter,
return;
}
// CardPictureLoader::getPixmap tags its output with the screen's
// devicePixelRatio on HiDPI displays. Every calculation below is in raw
// pixels, so normalize to 1.0 or the crop renders at 1/dpr scale anchored
// to the top left corner of the row.
art.setDevicePixelRatio(1.0);
const int cardH = rect.height() - 4;
const int totalW = cardRight - rect.left();
const int marginL = qRound(totalW * params.marginPctL);
@ -125,11 +176,14 @@ void UserListPainter::drawCardArt(QPainter *painter,
const int srcX = (scaledW - drawW) / 2;
const int srcY = qRound((scaledH - cardH) * params.verticalOffset);
// Clamp srcY so we never copy outside the pixmap bounds
// Clamp so we never copy outside the pixmap bounds. srcX can go negative
// for stored zoom values below 1, which would silently underfill the
// strip with transparent padding.
const int safeSrcX = qBound(0, srcX, qMax(0, scaledW - drawW));
const int safeSrcY = qBound(0, srcY, qMax(0, scaledH - cardH));
QImage img =
scaled.copy(srcX, safeSrcY, drawW, cardH).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
scaled.copy(safeSrcX, safeSrcY, drawW, cardH).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied);
{
QPainter mask(&img);
@ -163,7 +217,8 @@ void UserListPainter::drawAvatar(QPainter *painter,
const UserLevelFlags &userLevel,
const ServerInfo_User &userInfo,
const QString &privLevel,
const QMap<QString, QPixmap> *avatarCache)
const QMap<QString, QPixmap> *avatarCache,
const Style &style)
{
QPainterPath clipPath;
clipPath.addEllipse(avatarRect);
@ -183,7 +238,7 @@ void UserListPainter::drawAvatar(QPainter *painter,
}
if (!drewAvatar) {
painter->setBrush(accentColor.darker(200));
painter->setBrush(blend(accentColor, style.base, style.dark ? 0.45 : 0.72));
painter->setPen(Qt::NoPen);
painter->drawEllipse(avatarRect);
@ -196,9 +251,9 @@ void UserListPainter::drawAvatar(QPainter *painter,
painter->restore();
}
void UserListPainter::drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online)
void UserListPainter::drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online, const Style &style)
{
const QColor statusColor = online ? QColor(34, 197, 94) : QColor(70, 80, 95);
const QColor statusColor = online ? QColor(34, 197, 94) : style.ringOffline;
painter->setPen(QPen(statusColor, 2));
painter->setBrush(Qt::NoBrush);
@ -212,7 +267,7 @@ void UserListPainter::drawUserName(QPainter *painter,
int textX,
const QString &userName,
bool online,
bool selected)
const Style &style)
{
QFont nameFont = option.font;
nameFont.setBold(true);
@ -221,10 +276,12 @@ void UserListPainter::drawUserName(QPainter *painter,
const QRect nameRect(textX, rect.top() + 8, cardRight - textX - 10, 20);
const QString elidedName = QFontMetrics(nameFont).elidedText(userName, Qt::ElideRight, cardRight - textX - 10);
painter->setPen(QColor(0, 0, 0, 200));
painter->drawText(nameRect.translated(1, 1), Qt::AlignVCenter | Qt::AlignLeft, elidedName);
if (style.dropShadow) {
painter->setPen(QColor(0, 0, 0, 200));
painter->drawText(nameRect.translated(1, 1), Qt::AlignVCenter | Qt::AlignLeft, elidedName);
}
painter->setPen(online ? (selected ? Qt::white : QColor(226, 232, 240)) : QColor(90, 100, 115));
painter->setPen(online ? style.textOnline : style.textOffline);
painter->drawText(nameRect, Qt::AlignVCenter | Qt::AlignLeft, elidedName);
}
@ -262,7 +319,8 @@ void UserListPainter::drawBadges(QPainter *painter,
const QRect &rect,
int cardRight,
const QList<Badge> &badges,
bool online)
bool online,
const Style &style)
{
if (badges.isEmpty()) {
return;
@ -284,15 +342,17 @@ void UserListPainter::drawBadges(QPainter *painter,
int bx = cardRight - 6 - totalBadgeW;
for (const Badge &b : badges) {
const QColor col = online ? b.color : b.color.darker(180);
const QColor col = online ? b.color : blend(b.color, style.base, 0.55);
const QColor surface = blend(col, style.base, style.dark ? 0.55 : 0.78);
const QColor text = style.dark ? blend(col, Qt::white, 0.5) : blend(col, Qt::black, 0.35);
const int bw = fm.horizontalAdvance(b.text) + 8;
const QRect br(bx, rect.top() + 44, bw, 13);
painter->setPen(Qt::NoPen);
painter->setBrush(col.darker(online ? 160 : 220));
painter->setBrush(surface);
painter->drawRoundedRect(br, 3, 3);
painter->setPen(col.lighter(online ? 160 : 100));
painter->setPen(text);
painter->drawText(br, Qt::AlignCenter, b.text);
bx += bw + 4;
@ -305,11 +365,19 @@ void UserListPainter::paint(QPainter *painter,
const ServerInfo_User &userInfo,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap)
const QMap<QString, CardArtParams> *cardArtParamsMap,
bool dark)
{
painter->save();
painter->setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing);
// The delegate supplies the application palette in option.palette, which
// always reflects the active theme. The widget palette can be stale after
// a runtime theme change, so it is only used as a defensive fallback.
const QPalette pal =
option.palette == QPalette() ? (option.widget ? option.widget->palette() : qApp->palette()) : option.palette;
const Style style = resolveStyle(pal, dark);
const QRect rect = option.rect;
const bool online = index.data(Qt::UserRole + 1).toBool();
const bool selected = option.state & QStyle::State_Selected;
@ -317,6 +385,9 @@ void UserListPainter::paint(QPainter *painter,
const QString userName = QString::fromStdString(userInfo.name());
const QString privLevel = QString::fromStdString(userInfo.privlevel());
const QColor accentColor = getAccentColor(userLevel, online);
const bool hasRole = userLevel.testFlag(ServerInfo_User::IsAdmin) ||
userLevel.testFlag(ServerInfo_User::IsModerator) ||
userLevel.testFlag(ServerInfo_User::IsJudge);
const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2);
const int cardRight = getCardRight(option, rect);
@ -324,19 +395,19 @@ void UserListPainter::paint(QPainter *painter,
? cardArtParamsMap->value(userName)
: CardArtParams{};
drawBackground(painter, cardRect, accentColor, selected);
drawBackground(painter, cardRect, accentColor, selected, style, hasRole);
drawCardArt(painter, rect, cardRight, userName, cardArtCache, params);
const QRect avatarRect = getAvatarRect(rect);
drawAvatar(painter, avatarRect, userName, accentColor, userLevel, userInfo, privLevel, avatarCache);
drawStatusRing(painter, avatarRect, online);
drawAvatar(painter, avatarRect, userName, accentColor, userLevel, userInfo, privLevel, avatarCache, style);
drawStatusRing(painter, avatarRect, online, style);
const int textX = avatarRect.right() + TextSpacing;
drawUserName(painter, option, rect, cardRight, textX, userName, online, selected);
drawUserName(painter, option, rect, cardRight, textX, userName, online, style);
drawCountryFlag(painter, rect, textX, userInfo);
const QList<Badge> badges = buildBadges(userLevel, privLevel);
drawBadges(painter, option, rect, cardRight, badges, online);
drawBadges(painter, option, rect, cardRight, badges, online, style);
painter->restore();
}
}

View file

@ -6,6 +6,7 @@
#include <QColor>
#include <QList>
#include <QMap>
#include <QPalette>
#include <QPixmap>
#include <QRect>
#include <QSize>
@ -28,13 +29,36 @@ struct CardArtParams
class UserListPainter
{
public:
/**
* Palette-derived surface colors for the current color scheme. Both the
* light and the dark scheme read from the active QPalette so custom
* palettes are respected. @c dark only tunes the blend strengths (and
* whether the name text keeps its drop shadow).
*/
struct Style
{
bool dark = true;
QColor cardStart; ///< row fill, left edge (normal)
QColor cardEnd; ///< row fill, right edge (normal)
QColor base; ///< lightest surface, used for blending accent hues
QColor textOnline;
QColor textOffline;
QColor ringOffline;
bool dropShadow = false;
};
static Style resolveStyle(const QPalette &palette, bool dark);
/// Linear interpolation: @p t = 0 returns @p a, @p t = 1 returns @p b.
static QColor blend(const QColor &a, const QColor &b, qreal t);
static void paint(QPainter *painter,
const QStyleOptionViewItem &option,
const QModelIndex &index,
const ServerInfo_User &userInfo,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap);
const QMap<QString, CardArtParams> *cardArtParamsMap,
bool dark);
static QSize sizeHint();
@ -55,7 +79,12 @@ private:
static QColor getAccentColor(const UserLevelFlags &userLevel, bool online);
static int getCardRight(const QStyleOptionViewItem &option, const QRect &rect);
static void drawBackground(QPainter *painter, const QRectF &cardRect, const QColor &accentColor, bool selected);
static void drawBackground(QPainter *painter,
const QRectF &cardRect,
const QColor &accentColor,
bool selected,
const Style &style,
bool hasRole);
static QRect getAvatarRect(const QRect &rect);
static void drawAvatar(QPainter *painter,
const QRect &avatarRect,
@ -64,8 +93,9 @@ private:
const UserLevelFlags &userLevel,
const ServerInfo_User &userInfo,
const QString &privLevel,
const QMap<QString, QPixmap> *avatarCache);
static void drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online);
const QMap<QString, QPixmap> *avatarCache,
const Style &style);
static void drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online, const Style &style);
static void drawUserName(QPainter *painter,
const QStyleOptionViewItem &option,
const QRect &rect,
@ -73,7 +103,7 @@ private:
int textX,
const QString &userName,
bool online,
bool selected);
const Style &style);
static void drawCountryFlag(QPainter *painter, const QRect &rect, int textX, const ServerInfo_User &userInfo);
static QList<Badge> buildBadges(const UserLevelFlags &userLevel, const QString &privLevel);
static void drawBadges(QPainter *painter,
@ -81,7 +111,8 @@ private:
const QRect &rect,
int cardRight,
const QList<Badge> &badges,
bool online);
bool online,
const Style &style);
};
#endif // COCKATRICE_USER_LIST_PAINTER_H
#endif // COCKATRICE_USER_LIST_PAINTER_H

View file

@ -0,0 +1,88 @@
#include "user_list_panel_widget.h"
#include "../../../../client/settings/cache_settings.h"
#include "user_list_manager.h"
#include "user_list_widget.h"
#include <QLineEdit>
#include <QVBoxLayout>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/settings/interface_settings.h>
namespace
{
// The persisted section keys are the serialization contract with the user's
// settings file, so the values must stay stable across versions.
QString sectionKey(UserListWidget::Section section)
{
switch (section) {
case UserListWidget::Section::Buddy:
return QStringLiteral("buddy");
case UserListWidget::Section::Online:
return QStringLiteral("online");
case UserListWidget::Section::Ignore:
return QStringLiteral("ignore");
}
return {};
}
} // namespace
UserListPanelWidget::UserListPanelWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, QWidget *parent)
: QWidget(parent)
{
auto *mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(0, 0, 0, 0);
mainLayout->setSpacing(2);
searchBar = new QLineEdit(this);
searchBar->setClearButtonEnabled(true);
mainLayout->addWidget(searchBar);
userList = new UserListWidget(_tabSupervisor, _client, UserListWidget::RoomList, this);
userList->setSectioned(
{UserListWidget::Section::Buddy, UserListWidget::Section::Online, UserListWidget::Section::Ignore});
mainLayout->addWidget(userList, 1);
connect(searchBar, &QLineEdit::textChanged, userList, &UserListWidget::setFilterText);
connect(userList, &UserListWidget::sectionExpanded, this, &UserListPanelWidget::persistExpandedSections);
connect(userList, &UserListWidget::openMessageDialog, this, &UserListPanelWidget::openMessageDialog);
// Restore the persisted expansion state, then apply it to the tree.
const QStringList expandedSections = SettingsCache::instance().userInterface().getUserListExpandedSections();
for (const UserListWidget::Section section : userList->getSectionIds()) {
userList->setSectionExpanded(section, expandedSections.contains(sectionKey(section)));
}
retranslateUi();
}
void UserListPanelWidget::bind(UserListManager *manager)
{
userList->bind(manager);
}
void UserListPanelWidget::persistExpandedSections(UserListWidget::Section section, bool expanded)
{
const QString key = sectionKey(section);
QStringList expandedSections = SettingsCache::instance().userInterface().getUserListExpandedSections();
if (expanded) {
if (!expandedSections.contains(key)) {
expandedSections.append(key);
}
} else {
expandedSections.removeAll(key);
}
SettingsCache::instance().userInterface().setUserListExpandedSections(expandedSections);
}
void UserListPanelWidget::retranslateUi()
{
searchBar->setPlaceholderText(tr("Search users..."));
userList->retranslateUi();
}
UserListWidget *UserListPanelWidget::getUserList() const
{
return userList;
}

View file

@ -0,0 +1,43 @@
/**
* @file user_list_panel_widget.h
* @ingroup Lobby
*/
#ifndef COCKATRICE_USER_LIST_PANEL_WIDGET_H
#define COCKATRICE_USER_LIST_PANEL_WIDGET_H
#include "user_list_widget.h"
#include <QWidget>
class AbstractClient;
class QLineEdit;
class TabSupervisor;
class UserListManager;
/**
* A unified user list: a search bar above a single tree whose section headers
* (buddy, online, ignored) are inline dividers. The tree owns the scrolling.
*/
class UserListPanelWidget : public QWidget
{
Q_OBJECT
public:
explicit UserListPanelWidget(TabSupervisor *tabSupervisor, AbstractClient *client, QWidget *parent = nullptr);
void bind(UserListManager *manager);
void retranslateUi();
[[nodiscard]] UserListWidget *getUserList() const;
signals:
void openMessageDialog(const QString &userName, bool focus);
private:
void persistExpandedSections(UserListWidget::Section section, bool expanded);
QLineEdit *searchBar = nullptr;
UserListWidget *userList = nullptr;
};
#endif // COCKATRICE_USER_LIST_PANEL_WIDGET_H

View file

@ -8,6 +8,7 @@
#define USERLIST_H
#include "../../cards/card_info_picture_art_crop_widget.h"
#include "../../interface/widgets/server/game_link.h"
#include "user_avatar_provider.h"
#include "user_card_art_provider.h"
#include "user_info_popup.h"
@ -18,9 +19,11 @@
#include <QDialog>
#include <QGroupBox>
#include <QQueue>
#include <QSet>
#include <QStyledItemDelegate>
#include <QTextEdit>
#include <QTreeWidgetItem>
#include <functional>
#include <libcockatrice/network/server/remote/user_level.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
@ -36,6 +39,8 @@ class QPlainTextEdit;
class Response;
class CommandContainer;
class UserContextMenu;
class UserListWidget;
class QShowEvent;
class BanDialog : public QDialog
{
@ -80,7 +85,7 @@ public:
[[nodiscard]] QString getWarnID() const;
[[nodiscard]] QString getReason() const;
[[nodiscard]] int getDeleteMessages() const;
void addWarningOption(const QString warning);
void addWarningOption(const QString warning, int startingIl = 1);
};
class AdminNotesDialog : public QDialog
@ -102,12 +107,15 @@ public:
class UserListItemDelegate : public QStyledItemDelegate
{
QTreeWidget *tree;
UserListWidget *owner;
const QMap<QString, QPixmap> *avatarCache;
const QMap<QString, QPixmap> *cardArtCache;
const QMap<QString, CardArtParams> *cardArtParamsMap;
public:
explicit UserListItemDelegate(QObject *const parent,
explicit UserListItemDelegate(UserListWidget *owner,
QTreeWidget *tree,
const QMap<QString, QPixmap> *avatarCache,
const QMap<QString, QPixmap> *cardArtCache,
const QMap<QString, CardArtParams> *cardArtParamsMap);
@ -146,6 +154,12 @@ public:
BuddyList,
IgnoreList
};
enum class Section
{
Buddy,
Online,
Ignore
};
private:
UserListManager *manager = nullptr;
@ -153,30 +167,75 @@ private:
UserCardArtProvider *cardArtProvider = nullptr;
QMap<QString, CardArtParams> cardArtParamsMap;
// ── Hover popup ───────────────────────────────────────────────────────────
UserInfoPopup *m_userInfoPopup = nullptr;
QTimer *m_showPopupTimer = nullptr;
QTimer *m_hidePopupTimer = nullptr;
QString m_hoveredUser;
bool m_popupPinned = false;
UserInfoPopup *userInfoPopup = nullptr;
QTimer *showPopupTimer = nullptr;
QTimer *hidePopupTimer = nullptr;
QString hoveredUser;
bool popupPinned = false;
bool bulkLoading = false;
bool hasUserInfoPopup = true;
std::function<bool(const QString &userName, bool online)> userFilter;
void showPopupForUser(const QString &userName);
/**
* Popup functions are anchored on the row, not the user name. In sectioned
* mode a user can own several rows (online + buddy), and the popup must
* follow the hovered/selected row rather than a lookup by name.
*/
void showPopupForUser(UserListTWI *item);
void hidePopup(bool immediate = false);
void positionPopup(const QString &userName);
void positionPopup(UserListTWI *item);
void connectPopupSignals();
/** True when @p widget is the tree, the popup or an open menu. */
bool isPressInsideListUi(const QWidget *widget) const;
void clearSelectionAndClosePopup();
bool isItemNearViewport(const UserListTWI *item) const;
void requestVisibleItemResources();
// Sectioned mode (single tree with inline dividers)
bool sectioned = false;
QList<Section> sectionIds;
QMap<Section, QTreeWidgetItem *> sectionItems;
// One row per (section, user): a user that is online AND a buddy appears in
// both the "Online" and the "Buddies" sections, so the same user can own
// several rows, each hanging off its section's divider.
QMap<Section, QMap<QString, UserListTWI *>> sectionUsers;
QSet<Section> expandedSections;
void createSectionItems();
QTreeWidgetItem *createSectionItem(Section section);
[[nodiscard]] QString sectionTitle(Section section) const;
void updateSectionDivider(Section section);
void handleSectionExpansion(QTreeWidgetItem *item, bool expanded);
void setExpandedProgrammatically(QTreeWidgetItem *item, bool expanded);
void handleOnlineChange(const ServerInfo_User &user);
void handleOnlineChangeLeft(const QString &userName);
void handleListAdd(Section section, const ServerInfo_User &user);
void handleListRemove(Section section, const QString &userName);
/** Creates or updates the row for @p user in @p section. */
UserListTWI *ensureSectionMembership(Section section, const ServerInfo_User &user, bool online);
/** Removes and deletes the row for @p userName in @p section. */
bool dropSectionMembership(Section section, const QString &userName);
/** Sorts, refilters and repaints after a sectioned mode mutation. */
void finishSectionedMutation();
void updateCardArtParams(const ServerInfo_User &user, const QString &userName);
void processUserInfo(Section section, const ServerInfo_User &user, bool online);
QMap<QString, UserListTWI *> users;
TabSupervisor *tabSupervisor;
AbstractClient *client;
UserListType type;
QTreeWidget *userTree;
QTreeWidget *userTree = nullptr;
UserListItemDelegate *itemDelegate;
UserContextMenu *userContextMenu;
int onlineCount;
QString titleStr;
QString filterText;
bool showTitle = true;
void updateCount();
void applyFilter();
void refreshPopupButtons(const QString &userName);
private slots:
void userClicked(QTreeWidgetItem *item, int column);
void refreshVisibleUserHeader(const QString &name);
signals:
void openMessageDialog(const QString &userName, bool focus);
void addBuddy(const QString &userName);
@ -184,29 +243,60 @@ signals:
void addIgnore(const QString &userName);
void removeIgnore(const QString &userName);
void joinGameRequested(int gameId, int roomId, bool asSpectator);
void sectionExpanded(Section section, bool expanded);
/** Dialog mode: the user activated (Enter/double-click) the given row. */
void userActivated(const QString &userName);
/** Dialog mode: the current row changed; empty string means no user row. */
void currentUserChanged(const QString &userName);
/** The set of visible rows changed (filter, search or a live mutation). */
void userListChanged();
public:
UserListWidget(TabSupervisor *_tabSupervisor,
AbstractClient *_client,
UserListType _type,
QWidget *parent = nullptr);
QWidget *parent = nullptr,
bool hasUserInfoPopup = true);
~UserListWidget() override;
void bind(UserListManager *mgr);
void applyDisplayMode();
void beginBulkLoad();
void endBulkLoad();
bool eventFilter(QObject *obj, QEvent *event) override;
void retranslateUi();
void rebuild();
void processUserInfo(const ServerInfo_User &user, bool online);
bool deleteUser(const QString &userName);
void setUserOnline(const QString &userName, bool online);
void setFilterText(const QString &text);
void setShowTitle(bool showTitle);
void setSectioned(const QList<Section> &ids);
void setSectionExpanded(Section section, bool expanded);
/** Dialog mode: rows that fail the predicate are never shown. */
void setUserFilter(std::function<bool(const QString &userName, bool online)> filter)
{
userFilter = std::move(filter);
}
[[nodiscard]] int visibleUserRowCount() const;
[[nodiscard]] bool getHasUserInfoPopup() const
{
return hasUserInfoPopup;
}
[[nodiscard]] const QList<Section> &getSectionIds() const
{
return sectionIds;
}
[[nodiscard]] const QMap<QString, UserListTWI *> &getUsers() const
{
return users;
}
void showContextMenu(const QPoint &pos, const QModelIndex &index);
void sortItems();
void setGameInviteLinkProvider(std::function<QList<GameInviteOption>()> provider);
protected:
void hideEvent(QHideEvent *e) override;
void showEvent(QShowEvent *e) override;
};
#endif

View file

@ -5,13 +5,17 @@
#include "../../client/settings/card_counter_settings.h"
#include "../../palette_editor/palette_editor_dialog.h"
#include "../dialogs/override_printing_warning.h"
#include "../general/home_tab_button_color.h"
#include "../interface/theme_manager.h"
#include "../interface/widgets/general/background_sources.h"
#include "../playmat/playmat_collection_dialog.h"
#include "../playmat/playmat_settings_dialog.h"
#include <QApplication>
#include <QColorDialog>
#include <QDesktopServices>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QMessageBox>
#include <QStyleFactory>
#include <QTimer>
@ -128,6 +132,14 @@ AppearanceSettingsPage::AppearanceSettingsPage()
connect(&homeTabDisplayCardNameCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(),
&AppearanceSettings::setHomeTabDisplayCardName);
for (const auto &entry : HomeTabButtonColor::all()) {
homeTabButtonColorSourceBox.addItem(QObject::tr(entry.trKey));
}
homeTabButtonColorSourceBox.setCurrentIndex(settings.appearance().getHomeTabButtonColorSourceIndex());
connect(&homeTabButtonColorSourceBox, QOverload<int>::of(&QComboBox::currentIndexChanged), &settings.appearance(),
&AppearanceSettings::setHomeTabButtonColorSourceIndex);
updateHomeTabSettingsVisibility();
auto *homeTabGrid = new QGridLayout;
@ -136,6 +148,8 @@ AppearanceSettingsPage::AppearanceSettingsPage()
homeTabGrid->addWidget(&homeTabBackgroundShuffleFrequencyLabel, 1, 0);
homeTabGrid->addWidget(&homeTabBackgroundShuffleFrequencySpinBox, 1, 1);
homeTabGrid->addWidget(&homeTabDisplayCardNameCheckBox, 2, 0, 1, 2);
homeTabGrid->addWidget(&homeTabButtonColorSourceLabel, 3, 0);
homeTabGrid->addWidget(&homeTabButtonColorSourceBox, 3, 1);
homeTabGroupBox = new QGroupBox;
homeTabGroupBox->setLayout(homeTabGrid);
@ -325,10 +339,52 @@ AppearanceSettingsPage::AppearanceSettingsPage()
tableGroupBox = new QGroupBox;
tableGroupBox->setLayout(tableGrid);
// Playmat settings
playmatVisibilityCombo.addItem(tr("Show all playmats"), PlaymatVisibilityAll);
playmatVisibilityCombo.addItem(tr("Show own playmat only"), PlaymatVisibilityOwnOnly);
playmatVisibilityCombo.addItem(tr("Don't use playmats"), PlaymatVisibilityNone);
int visIdx = playmatVisibilityCombo.findData(settings.userInterface().getPlaymatVisibility());
if (visIdx >= 0) {
playmatVisibilityCombo.setCurrentIndex(visIdx);
}
connect(&playmatVisibilityCombo, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
SettingsCache::instance().userInterface().setPlaymatVisibility(playmatVisibilityCombo.itemData(index).toInt());
});
playmatVisibilityLabel.setBuddy(&playmatVisibilityCombo);
// Playmat mode: Override / Fallback / Deck-only
playmatModeCombo.addItem(tr("Override deck playmat"), PlaymatModeOverrideDeck);
playmatModeCombo.addItem(tr("Fallback if deck has none"), PlaymatModeFallback);
playmatModeCombo.addItem(tr("Deck only, ignore collection"), PlaymatModeDeckOnly);
int modeIdx = playmatModeCombo.findData(settings.userInterface().getPlaymatMode());
if (modeIdx >= 0) {
playmatModeCombo.setCurrentIndex(modeIdx);
}
connect(&playmatModeCombo, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
SettingsCache::instance().userInterface().setPlaymatMode(playmatModeCombo.itemData(index).toInt());
});
playmatModeLabel.setBuddy(&playmatModeCombo);
// User-level playmat settings: fallback collection.
connect(&playmatDefaultEditButton, &QPushButton::clicked, this,
&AppearanceSettingsPage::openPlaymatCollectionDialog);
auto *playmatGrid = new QGridLayout;
playmatGrid->addWidget(&playmatVisibilityLabel, 0, 0, 1, 1);
playmatGrid->addWidget(&playmatVisibilityCombo, 0, 1, 1, 1);
playmatGrid->addWidget(&playmatModeLabel, 1, 0, 1, 1);
playmatGrid->addWidget(&playmatModeCombo, 1, 1, 1, 1);
playmatGrid->addWidget(&playmatDefaultLabel, 2, 0, 1, 1);
playmatGrid->addWidget(&playmatDefaultEditButton, 2, 1, 1, 1);
playmatGroupBox = new QGroupBox;
playmatGroupBox->setLayout(playmatGrid);
// putting it all together
auto *mainLayout = new QVBoxLayout;
mainLayout->addWidget(themeGroupBox);
mainLayout->addWidget(homeTabGroupBox);
mainLayout->addWidget(playmatGroupBox);
mainLayout->addWidget(stylingGroupBox);
mainLayout->addWidget(menuGroupBox);
mainLayout->addWidget(printingsGroupBox);
@ -375,8 +431,8 @@ void AppearanceSettingsPage::editPalette()
void AppearanceSettingsPage::updateHomeTabSettingsVisibility()
{
bool visible = SettingsCache::instance().appearance().getHomeTabBackgroundSource() !=
BackgroundSources::toId(BackgroundSources::Theme);
QString sourceId = SettingsCache::instance().appearance().getHomeTabBackgroundSource();
bool visible = BackgroundSources::fromId(sourceId) != BackgroundSources::Theme;
homeTabBackgroundShuffleFrequencyLabel.setVisible(visible);
homeTabBackgroundShuffleFrequencySpinBox.setVisible(visible);
@ -431,6 +487,12 @@ void AppearanceSettingsPage::cardViewExpandedRowsMaxChanged(int value)
}
}
void AppearanceSettingsPage::openPlaymatCollectionDialog()
{
PlaymatCollectionDialog dialog(this);
dialog.exec();
}
void AppearanceSettingsPage::retranslateUi()
{
themeGroupBox->setTitle(tr("Theme settings"));
@ -446,6 +508,9 @@ void AppearanceSettingsPage::retranslateUi()
homeTabBackgroundShuffleFrequencyLabel.setText(tr("Home tab background shuffle frequency:"));
homeTabBackgroundShuffleFrequencySpinBox.setSpecialValueText(tr("Disabled"));
homeTabDisplayCardNameCheckBox.setText(tr("Display card name of background in bottom right"));
homeTabButtonColorSourceLabel.setText(tr("Home tab button color:"));
homeTabButtonColorSourceBox.setToolTip(
tr("Automatic: extract from background if present, otherwise use theme default"));
stylingGroupBox->setTitle(tr("Styling settings"));
styleUserListCheckBox.setText(tr("Style user list"));
@ -489,4 +554,9 @@ void AppearanceSettingsPage::retranslateUi()
tableGroupBox->setTitle(tr("Table grid layout"));
invertVerticalCoordinateCheckBox.setText(tr("Invert vertical coordinate"));
minPlayersForMultiColumnLayoutLabel.setText(tr("Minimum player count for multi-column layout:"));
}
playmatGroupBox->setTitle(tr("Playmat settings"));
playmatVisibilityLabel.setText(tr("Playmat visibility:"));
playmatModeLabel.setText(tr("Default collection behavior:"));
playmatDefaultLabel.setText(tr("Default playmat collection:"));
playmatDefaultEditButton.setText(tr("Edit..."));
}

View file

@ -24,6 +24,7 @@ private slots:
void cardViewInitialRowsMaxChanged(int value);
void cardViewExpandedRowsMaxChanged(int value);
void openPlaymatCollectionDialog();
private:
QLabel themeLabel;
@ -34,11 +35,15 @@ private:
QLabel styleComboLabel;
QComboBox styleCombo;
QPushButton editPaletteButton;
QLabel homeTabBackgroundSourceLabel;
QComboBox homeTabBackgroundSourceBox;
QLabel homeTabBackgroundShuffleFrequencyLabel;
QSpinBox homeTabBackgroundShuffleFrequencySpinBox;
QCheckBox homeTabDisplayCardNameCheckBox;
QLabel homeTabButtonColorSourceLabel;
QComboBox homeTabButtonColorSourceBox;
QCheckBox styleUserListCheckBox;
QCheckBox showShortcutsCheckBox;
QCheckBox showGameSelectorFilterToolbarCheckBox;
@ -59,6 +64,12 @@ private:
QCheckBox horizontalHandCheckBox;
QCheckBox leftJustifiedHandCheckBox;
QCheckBox invertVerticalCoordinateCheckBox;
QLabel playmatVisibilityLabel;
QComboBox playmatVisibilityCombo;
QLabel playmatModeLabel;
QComboBox playmatModeCombo;
QLabel playmatDefaultLabel;
QPushButton playmatDefaultEditButton;
QGroupBox *themeGroupBox;
QGroupBox *homeTabGroupBox;
QGroupBox *stylingGroupBox;
@ -67,6 +78,7 @@ private:
QGroupBox *cardsGroupBox;
QGroupBox *cardLayoutGroupBox;
QGroupBox *handGroupBox;
QGroupBox *playmatGroupBox;
QGroupBox *tableGroupBox;
QGroupBox *cardCountersGroupBox;
QList<QLabel *> cardCounterNames;

View file

@ -2,6 +2,7 @@
#include "../../../client/settings/cache_settings.h"
#include "../main.h"
#include "../server/user/user_info_connection.h"
#include "update/client/release_channel.h"
#include <QCoreApplication>
@ -11,6 +12,7 @@
#include <QTranslator>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <libcockatrice/settings/tabs_settings.h>
#include <libcockatrice/settings/updates_settings.h>
#include <libcockatrice/utility/macros.h>
@ -121,8 +123,61 @@ GeneralSettingsPage::GeneralSettingsPage()
connect(&showTipsOnStartup, &QCheckBox::clicked, &settings.personal(), &PersonalSettings::setShowTipsOnStartup);
// startup destination
for (int i = 0; i < 8; ++i) {
startupTabSelector.addItem(""); // texts set in retranslateUi
}
startupTabSelector.setCurrentIndex(settings.tabs().getStartupTabIndex());
connect(&startupTabSelector, qOverload<int>(&QComboBox::currentIndexChanged), &settings.tabs(),
&TabsSettings::setStartupTabIndex);
connect(&startupTabSelector, qOverload<int>(&QComboBox::currentIndexChanged), this,
&GeneralSettingsPage::updateStartupServerControlsVisibility);
const QString savedHost = settings.tabs().getStartupServerHost();
const QString savedPort = settings.tabs().getStartupServerPort();
int startupServerIndex = -1;
UserConnection_Information uci;
for (const auto &savedServer : uci.getServerInfo()) {
const UserConnection_Information &info = savedServer.second;
const QString saveName = info.getSaveName();
if (saveName.isEmpty()) {
continue;
}
startupServerSelector.addItem(saveName, QVariantList{info.getServer(), info.getPort()});
if (startupServerIndex == -1 && info.getServer() == savedHost && info.getPort() == savedPort) {
startupServerIndex = startupServerSelector.count() - 1;
}
}
startupServerSelector.setCurrentIndex(startupServerIndex);
connect(&startupServerSelector, qOverload<int>(&QComboBox::currentIndexChanged), this, [this](int index) {
const QVariantList serverInfo = startupServerSelector.itemData(index).toList();
if (serverInfo.size() != 2) {
return;
}
TabsSettings &tabs = SettingsCache::instance().tabs();
tabs.setStartupServerHost(serverInfo[0].toString());
tabs.setStartupServerPort(serverInfo[1].toString());
});
startupRoomNameEdit = new QLineEdit(settings.tabs().getStartupRoomName());
// Default (Expanding) would stretch the whole controls column when this row becomes visible,
// so size it like the combo boxes instead: fills the column, never widens it.
startupRoomNameEdit->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
connect(startupRoomNameEdit, &QLineEdit::editingFinished, this,
[this] { SettingsCache::instance().tabs().setStartupRoomName(startupRoomNameEdit->text().trimmed()); });
auto *startupGrid = new QGridLayout;
startupGrid->addWidget(&showTipsOnStartup, 0, 0, 1, 2);
startupGrid->addWidget(&startupTabLabel, 1, 0);
startupGrid->addWidget(&startupTabSelector, 1, 1);
startupGrid->addWidget(&startupServerLabel, 2, 0);
startupGrid->addWidget(&startupServerSelector, 2, 1);
startupGrid->addWidget(&startupRoomLabel, 3, 0);
startupGrid->addWidget(startupRoomNameEdit, 3, 1);
updateStartupServerControlsVisibility();
startupGroupBox = new QGroupBox;
startupGroupBox->setLayout(startupGrid);
@ -357,6 +412,17 @@ void GeneralSettingsPage::languageBoxChanged(int index)
SettingsCache::instance().personal().setLang(languageBox.itemData(index).toString());
}
void GeneralSettingsPage::updateStartupServerControlsVisibility()
{
const int index = startupTabSelector.currentIndex();
const bool serverNeeded = index == StartupTab::StartupTabServer || index == StartupTab::StartupTabServerRoom;
const bool roomNeeded = index == StartupTab::StartupTabServerRoom;
startupServerLabel.setVisible(serverNeeded);
startupServerSelector.setVisible(serverNeeded);
startupRoomLabel.setVisible(roomNeeded);
startupRoomNameEdit->setVisible(roomNeeded);
}
void GeneralSettingsPage::retranslateUi()
{
languageGroupBox->setTitle(tr("Language settings"));
@ -393,6 +459,20 @@ void GeneralSettingsPage::retranslateUi()
updateNotificationCheckBox.setText(tr("Notify if a feature supported by the server is missing in my client"));
newVersionOracleCheckBox.setText(tr("Automatically run Oracle when running a new version of Cockatrice"));
showTipsOnStartup.setText(tr("Show tips on startup"));
startupTabLabel.setText(tr("Startup tab:"));
startupTabSelector.setItemText(StartupTab::StartupTabHome, tr("Home"));
startupTabSelector.setItemText(StartupTab::StartupTabVisualDeckStorage, tr("Visual Deck Storage"));
startupTabSelector.setItemText(StartupTab::StartupTabDeckStorage, tr("Deck Storage"));
startupTabSelector.setItemText(StartupTab::StartupTabReplays, tr("Game Replays"));
startupTabSelector.setItemText(StartupTab::StartupTabDeckEditor, tr("Deck Editor"));
startupTabSelector.setItemText(StartupTab::StartupTabVisualDeckEditor, tr("Visual Deck Editor"));
startupTabSelector.setItemText(StartupTab::StartupTabServer, tr("Server"));
startupTabSelector.setItemText(StartupTab::StartupTabServerRoom, tr("Server Room"));
startupTabSelector.setToolTip(
tr("The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened."));
startupServerLabel.setText(tr("Server:"));
startupRoomLabel.setText(tr("Room:"));
startupRoomNameEdit->setPlaceholderText(tr("Room name"));
resetAllPathsButton->setText(tr("Reset all paths"));
const auto &settings = SettingsCache::instance();

View file

@ -20,6 +20,9 @@ public:
GeneralSettingsPage();
void retranslateUi() override;
static QStringList findQmFiles();
static QString languageName(const QString &lang);
private slots:
void deckPathButtonClicked();
void filtersPathButtonClicked();
@ -30,11 +33,9 @@ private slots:
void tokenDatabasePathButtonClicked();
void resetAllPathsClicked();
void languageBoxChanged(int index);
void updateStartupServerControlsVisibility();
private:
QStringList findQmFiles();
QString languageName(const QString &lang);
QGroupBox *languageGroupBox;
QGroupBox *versionGroupBox;
QGroupBox *cardDatabaseGroupBox;
@ -71,6 +72,12 @@ private:
QLabel updateReleaseChannelLabel;
QLabel advertiseTranslationPageLabel;
QCheckBox showTipsOnStartup;
QLabel startupTabLabel;
QComboBox startupTabSelector;
QLabel startupServerLabel;
QComboBox startupServerSelector;
QLabel startupRoomLabel;
QLineEdit *startupRoomNameEdit;
};
#endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H

View file

@ -116,8 +116,29 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
connect(&tapAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(),
&CardsDisplaySettings::setTapAnimation);
arrowDrawAnimationCheckBox.setChecked(SettingsCache::instance().cardsDisplay().getArrowDrawAnimation());
connect(&arrowDrawAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(),
&CardsDisplaySettings::setArrowDrawAnimation);
lifeCounterAnimationsCheckBox.setChecked(
SettingsCache::instance().userInterface().getLifeCounterAnimationsEnabled());
connect(&lifeCounterAnimationsCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
&InterfaceSettings::setLifeCounterAnimationsEnabled);
battlefieldFlashCheckBox.setChecked(SettingsCache::instance().userInterface().getBattlefieldFlashEnabled());
connect(&battlefieldFlashCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(),
&InterfaceSettings::setBattlefieldFlashEnabled);
connect(&enableAllAnimationsButton, &QPushButton::clicked, this, &UserInterfaceSettingsPage::enableAllAnimations);
connect(&disableAllAnimationsButton, &QPushButton::clicked, this, &UserInterfaceSettingsPage::disableAllAnimations);
auto *animationGrid = new QGridLayout;
animationGrid->addWidget(&tapAnimationCheckBox, 0, 0);
animationGrid->addWidget(&enableAllAnimationsButton, 0, 0);
animationGrid->addWidget(&disableAllAnimationsButton, 0, 1);
animationGrid->addWidget(&tapAnimationCheckBox, 1, 0);
animationGrid->addWidget(&arrowDrawAnimationCheckBox, 2, 0);
animationGrid->addWidget(&lifeCounterAnimationsCheckBox, 3, 0);
animationGrid->addWidget(&battlefieldFlashCheckBox, 4, 0);
animationGroupBox = new QGroupBox;
animationGroupBox->setLayout(animationGrid);
@ -162,6 +183,13 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
connect(&defaultDeckEditorTypeSelector, QOverload<int>::of(&QComboBox::currentIndexChanged),
&SettingsCache::instance().deckEditor(), &DeckEditorSettings::setDefaultDeckEditorType);
vdeStartupTabSelector.addItem(""); // these will be set in retranslateUI
vdeStartupTabSelector.addItem("");
vdeStartupTabSelector.addItem("");
vdeStartupTabSelector.setCurrentIndex(SettingsCache::instance().deckEditor().getVdeStartupTab());
connect(&vdeStartupTabSelector, QOverload<int>::of(&QComboBox::currentIndexChanged),
&SettingsCache::instance().deckEditor(), &DeckEditorSettings::setVdeStartupTab);
commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setText("?");
commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setAutoRaise(true);
commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setEnabled(false);
@ -221,10 +249,12 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage()
deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionSelector, 3, 1);
deckEditorGrid->addWidget(&defaultDeckEditorTypeLabel, 4, 0);
deckEditorGrid->addWidget(&defaultDeckEditorTypeSelector, 4, 1);
deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledLabel, 5, 0);
deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledSelector, 5, 1);
deckEditorGrid->addWidget(labelWidget, 6, 0);
deckEditorGrid->addWidget(&commanderSpellbookIntegrationBracketNamingSelector, 6, 1);
deckEditorGrid->addWidget(&vdeStartupTabLabel, 5, 0);
deckEditorGrid->addWidget(&vdeStartupTabSelector, 5, 1);
deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledLabel, 6, 0);
deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledSelector, 6, 1);
deckEditorGrid->addWidget(labelWidget, 7, 0);
deckEditorGrid->addWidget(&commanderSpellbookIntegrationBracketNamingSelector, 7, 1);
deckEditorGroupBox = new QGroupBox;
deckEditorGroupBox->setLayout(deckEditorGrid);
@ -268,6 +298,22 @@ void UserInterfaceSettingsPage::setNotificationEnabled(QT_STATE_CHANGED_T i)
}
}
void UserInterfaceSettingsPage::enableAllAnimations()
{
tapAnimationCheckBox.setChecked(true);
arrowDrawAnimationCheckBox.setChecked(true);
lifeCounterAnimationsCheckBox.setChecked(true);
battlefieldFlashCheckBox.setChecked(true);
}
void UserInterfaceSettingsPage::disableAllAnimations()
{
tapAnimationCheckBox.setChecked(false);
arrowDrawAnimationCheckBox.setChecked(false);
lifeCounterAnimationsCheckBox.setChecked(false);
battlefieldFlashCheckBox.setChecked(false);
}
void UserInterfaceSettingsPage::updateCommanderSpellbookUiState()
{
const int mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled();
@ -310,7 +356,12 @@ void UserInterfaceSettingsPage::retranslateUi()
specNotificationsEnabledCheckBox.setText(tr("Notify in the taskbar for game events while you are spectating"));
buddyConnectNotificationsEnabledCheckBox.setText(tr("Notify in the taskbar when users in your buddy list connect"));
animationGroupBox->setTitle(tr("Animation settings"));
enableAllAnimationsButton.setText(tr("&Enable all animations"));
disableAllAnimationsButton.setText(tr("&Disable all animations"));
tapAnimationCheckBox.setText(tr("&Tap/untap animation"));
arrowDrawAnimationCheckBox.setText(tr("&Arrow draw animation"));
lifeCounterAnimationsCheckBox.setText(tr("Life counter flash"));
battlefieldFlashCheckBox.setText(tr("Battlefield flash on damage"));
deckEditorGroupBox->setTitle(tr("Deck editor/storage settings"));
openDeckInNewTabCheckBox.setText(tr("Open deck in new tab by default"));
visualDeckStorageInGameCheckBox.setText(tr("Use visual deck storage in game lobby"));
@ -326,6 +377,12 @@ void UserInterfaceSettingsPage::retranslateUi()
defaultDeckEditorTypeLabel.setText(tr("Default deck editor type"));
defaultDeckEditorTypeSelector.setItemText(TabSupervisor::ClassicDeckEditor, tr("Classic Deck Editor"));
defaultDeckEditorTypeSelector.setItemText(TabSupervisor::VisualDeckEditor, tr("Visual Deck Editor"));
vdeStartupTabLabel.setText(tr("Visual deck editor startup tab"));
vdeStartupTabSelector.setItemText(VdeStartupTabContext, tr("Context"));
vdeStartupTabSelector.setItemText(VdeStartupTabDeckDisplay, tr("Deck display"));
vdeStartupTabSelector.setItemText(VdeStartupTabDatabaseDisplay, tr("Database display"));
vdeStartupTabSelector.setToolTip(
tr("Context mode: New decks open on the database display, existing decks open on the deck view."));
commanderSpellbookIntegrationEnabledLabel.setText(
tr("CommanderSpellbook integration to estimate commander bracket"));

View file

@ -7,6 +7,7 @@
#include <QComboBox>
#include <QGroupBox>
#include <QLabel>
#include <QPushButton>
#include <QSpinBox>
#include <QToolButton>
#include <libcockatrice/settings/cards_display_settings.h>
@ -17,6 +18,8 @@ class UserInterfaceSettingsPage : public AbstractSettingsPage
Q_OBJECT
private slots:
void setNotificationEnabled(QT_STATE_CHANGED_T);
void enableAllAnimations();
void disableAllAnimations();
void updateCommanderSpellbookUiState();
private:
@ -34,7 +37,12 @@ private:
QCheckBox showTotalSelectionCountCheckBox;
QCheckBox useTearOffMenusCheckBox;
QCheckBox keepGameChatFocusCheckBox;
QPushButton enableAllAnimationsButton;
QPushButton disableAllAnimationsButton;
QCheckBox tapAnimationCheckBox;
QCheckBox arrowDrawAnimationCheckBox;
QCheckBox lifeCounterAnimationsCheckBox;
QCheckBox battlefieldFlashCheckBox;
QCheckBox openDeckInNewTabCheckBox;
QLabel visualDeckStoragePromptForConversionLabel;
QComboBox visualDeckStoragePromptForConversionSelector;
@ -42,6 +50,8 @@ private:
QCheckBox visualDeckStorageSelectionAnimationCheckBox;
QLabel defaultDeckEditorTypeLabel;
QComboBox defaultDeckEditorTypeSelector;
QLabel vdeStartupTabLabel;
QComboBox vdeStartupTabSelector;
QLabel commanderSpellbookIntegrationEnabledLabel;
QComboBox commanderSpellbookIntegrationEnabledSelector;
QLabel commanderSpellbookIntegrationUseOfficialBracketNamesLabel;

View file

@ -8,6 +8,7 @@
#ifndef TAB_GENERIC_DECK_EDITOR_H
#define TAB_GENERIC_DECK_EDITOR_H
#include "../../deck_loader/deck_loader.h"
#include "../interface/widgets/deck_editor/deck_editor_card_database_dock_widget.h"
#include "../interface/widgets/deck_editor/deck_editor_card_info_dock_widget.h"
#include "../interface/widgets/deck_editor/deck_editor_database_display_widget.h"
@ -263,12 +264,12 @@ protected slots:
/** @brief Handles dock close events. */
void closeEvent(QCloseEvent *event) override;
private:
/** @brief Sets the deck for this tab.
* @param _deck The deck object.
*/
virtual void setDeck(const LoadedDeck &_deck);
private:
/** @brief Helper for editing decks from the clipboard. */
void editDeckInClipboard(bool annotated);

View file

@ -2,12 +2,12 @@
#include "../../../../../client/settings/cache_settings.h"
#include "../../../cards/additional_info/mana_symbol_widget.h"
#include "../../../utility/completer_utils.h"
#include "../../tab_supervisor.h"
#include "api_response/archidekt_deck_listing_api_response.h"
#include "display/archidekt_api_response_deck_display_widget.h"
#include "display/archidekt_api_response_deck_listings_display_widget.h"
#include <QCompleter>
#include <QDebug>
#include <QFormLayout>
#include <QGridLayout>
@ -19,7 +19,6 @@
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QPushButton>
#include <QRegularExpression>
#include <QResizeEvent>
#include <QScrollArea>
#include <QScrollBar>
@ -279,41 +278,14 @@ void TabArchidekt::setupFilterWidgets()
auto cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this);
auto displayModel = new CardDatabaseDisplayModel(this);
displayModel->setSourceModel(cardDatabaseModel);
auto *searchModel = new CardSearchModel(displayModel, this);
auto *proxyModel = new CardCompleterProxyModel(this);
proxyModel->setSourceModel(searchModel);
proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
proxyModel->setFilterRole(Qt::DisplayRole);
auto *completer = new QCompleter(proxyModel, this);
completer->setCompletionRole(Qt::DisplayRole);
completer->setCompletionMode(QCompleter::PopupCompletion);
completer->setCaseSensitivity(Qt::CaseInsensitive);
completer->setFilterMode(Qt::MatchContains);
completer->setMaxVisibleItems(10);
cardsField->setCompleter(completer);
commandersField->setCompleter(completer);
const CardCompleterSetup cardSetup = createCardCompleter(displayModel, this);
cardsField->setCompleter(cardSetup.completer);
commandersField->setCompleter(cardSetup.completer);
// Keep autocomplete working for both fields
connect(cardsField, &QLineEdit::textChanged, this, [=](const QString &text) {
searchModel->updateSearchResults(text);
QString pattern = ".*" + QRegularExpression::escape(text) + ".*";
proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption));
if (!text.isEmpty()) {
completer->complete();
}
});
connect(commandersField, &QLineEdit::textChanged, this, [=](const QString &text) {
searchModel->updateSearchResults(text);
QString pattern = ".*" + QRegularExpression::escape(text) + ".*";
proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption));
if (!text.isEmpty()) {
completer->complete();
}
});
connectCardCompleterSearch(cardsField, cardSetup);
connectCardCompleterSearch(commandersField, cardSetup);
// Assemble secondary toolbar
secondaryToolbarLayout->addWidget(bracketLabel);

View file

@ -1,6 +1,7 @@
#include "tab_edhrec_main.h"
#include "../../../../../client/settings/cache_settings.h"
#include "../../../utility/completer_utils.h"
#include "../../tab_supervisor.h"
#include "api_response/average_deck/edhrec_average_deck_api_response.h"
#include "api_response/commander/edhrec_commander_api_response.h"
@ -12,7 +13,6 @@
#include "display/top_commander/edhrec_top_commanders_api_response_display_widget.h"
#include "display/top_tags/edhrec_top_tags_api_response_display_widget.h"
#include <QCompleter>
#include <QDebug>
#include <QHBoxLayout>
#include <QJsonArray>
@ -63,32 +63,12 @@ TabEdhRecMain::TabEdhRecMain(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor
auto cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this);
auto displayModel = new CardDatabaseDisplayModel(this);
displayModel->setSourceModel(cardDatabaseModel);
auto *searchModel = new CardSearchModel(displayModel, this);
auto *proxyModel = new CardCompleterProxyModel(this);
proxyModel->setSourceModel(searchModel);
proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
proxyModel->setFilterRole(Qt::DisplayRole);
auto *completer = new QCompleter(proxyModel, this);
completer->setCompletionRole(Qt::DisplayRole);
completer->setCompletionMode(QCompleter::PopupCompletion);
completer->setCaseSensitivity(Qt::CaseInsensitive);
completer->setFilterMode(Qt::MatchContains);
completer->setMaxVisibleItems(10);
searchBar->setCompleter(completer);
const CardCompleterSetup cardSetup = createCardCompleter(displayModel, this);
searchBar->setCompleter(cardSetup.completer);
// Update suggestions dynamically
connect(searchBar, &QLineEdit::textChanged, searchModel, &CardSearchModel::updateSearchResults);
connect(searchBar, &QLineEdit::textChanged, this, [=](const QString &text) {
// Ensure substring matching
QString pattern = ".*" + QRegularExpression::escape(text) + ".*";
proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption));
if (!text.isEmpty()) {
completer->complete(); // Force the dropdown to appear
}
});
connectCardCompleterSearch(searchBar, cardSetup);
searchPushButton = new QPushButton(navigationContainer);
connect(searchPushButton, &QPushButton::clicked, this, [=, this]() { doSearch(); });

View file

@ -20,6 +20,7 @@ class Tab : public QMainWindow
signals:
void userEvent(bool globalEvent = true);
void tabTextChanged(Tab *tab, const QString &newTabText);
void cockatriceLinkActivated(const QString &url);
protected:
TabSupervisor *tabSupervisor;

View file

@ -1,6 +1,7 @@
#include "tab_account.h"
#include "../client/sound_engine.h"
#include "../interface/widgets/dialogs/dlg_my_reports.h"
#include "../interface/widgets/server/user/user_info_box.h"
#include "../interface/widgets/server/user/user_list_manager.h"
#include "../interface/widgets/server/user/user_list_widget.h"
@ -49,6 +50,11 @@ TabAccount::TabAccount(TabSupervisor *_tabSupervisor, AbstractClient *_client, c
auto *vbox = new QVBoxLayout;
vbox->addWidget(userInfoBox);
myReportsButton = new QPushButton(tr("My Reports"));
connect(myReportsButton, &QPushButton::clicked, this, &TabAccount::openMyReports);
vbox->addWidget(myReportsButton);
vbox->addWidget(allUsersList);
auto *addToBuddyList = new QHBoxLayout;
@ -126,6 +132,7 @@ void TabAccount::addToList(const std::string &listName, const QString &userName)
void TabAccount::retranslateUi()
{
myReportsButton->setText(tr("My Reports"));
allUsersList->retranslateUi();
buddyList->retranslateUi();
ignoreList->retranslateUi();
@ -135,6 +142,7 @@ void TabAccount::retranslateUi()
void TabAccount::processListUsersResponse(const Response &response)
{
const Response_ListUsers &resp = response.GetExtension(Response_ListUsers::ext);
allUsersList->beginBulkLoad();
for (int i = 0; i < resp.user_list_size(); ++i) {
const ServerInfo_User &info = resp.user_list(i);
const QString &userName = QString::fromStdString(info.name());
@ -142,8 +150,8 @@ void TabAccount::processListUsersResponse(const Response &response)
ignoreList->setUserOnline(userName, true);
buddyList->setUserOnline(userName, true);
}
allUsersList->endBulkLoad();
allUsersList->sortItems();
ignoreList->sortItems();
buddyList->sortItems();
}
@ -188,18 +196,20 @@ void TabAccount::processUserLeftEvent(const Event_UserLeft &event)
void TabAccount::buddyListReceived(const QList<ServerInfo_User> &_buddyList)
{
buddyList->beginBulkLoad();
for (const auto &user : _buddyList) {
buddyList->processUserInfo(user, false);
}
buddyList->sortItems();
buddyList->endBulkLoad();
}
void TabAccount::ignoreListReceived(const QList<ServerInfo_User> &_ignoreList)
{
ignoreList->beginBulkLoad();
for (const auto &user : _ignoreList) {
ignoreList->processUserInfo(user, false);
}
ignoreList->sortItems();
ignoreList->endBulkLoad();
}
void TabAccount::processAddToListEvent(const Event_AddToList &event)
@ -237,3 +247,10 @@ void TabAccount::processRemoveFromListEvent(const Event_RemoveFromList &event)
userList->deleteUser(user);
}
void TabAccount::openMyReports()
{
auto *dlg = new DlgMyReports(client, this);
dlg->setAttribute(Qt::WA_DeleteOnClose);
dlg->exec();
}

View file

@ -18,6 +18,7 @@ class Event_RemoveFromList;
class Event_UserJoined;
class Event_UserLeft;
class LineEditUnfocusable;
class QPushButton;
class Response;
class ServerInfo_User;
class UserInfoBox;
@ -41,6 +42,7 @@ private slots:
void processRemoveFromListEvent(const Event_RemoveFromList &event);
void addToIgnoreList();
void addToBuddyList();
void openMyReports();
private:
AbstractClient *client;
@ -50,6 +52,7 @@ private:
UserInfoBox *userInfoBox;
LineEditUnfocusable *addBuddyEdit;
LineEditUnfocusable *addIgnoreEdit;
QPushButton *myReportsButton;
void addToList(const std::string &listName, const QString &userName);
public:

View file

@ -1,5 +1,6 @@
#include "tab_card_art_rules.h"
#include "../utility/completer_utils.h"
#include "libcockatrice/card/database/card_database_manager.h"
#include <QCompleter>
@ -194,29 +195,14 @@ void TabCardArtRules::initSearchBar()
cardDbModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this);
cardDbDisplayModel = new CardDatabaseDisplayModel(this);
cardDbDisplayModel->setSourceModel(cardDbModel);
cardSearchModel = new CardSearchModel(cardDbDisplayModel, this);
cardProxyModel = new CardCompleterProxyModel(this);
cardProxyModel->setSourceModel(cardSearchModel);
cardProxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
searchCompleter = new QCompleter(cardProxyModel, this);
searchCompleter->setCompletionRole(Qt::DisplayRole);
searchCompleter->setCompletionMode(QCompleter::PopupCompletion);
searchCompleter->setCaseSensitivity(Qt::CaseInsensitive);
searchCompleter->setFilterMode(Qt::MatchContains);
searchCompleter->setMaxVisibleItems(15);
const CardCompleterSetup cardSetup = createCardCompleter(cardDbDisplayModel, this, 15);
cardSearchModel = cardSetup.searchModel;
cardProxyModel = cardSetup.proxyModel;
searchCompleter = cardSetup.completer;
searchEdit->setCompleter(searchCompleter);
connect(searchEdit, &QLineEdit::textEdited, cardSearchModel, &CardSearchModel::updateSearchResults);
connect(searchEdit, &QLineEdit::textEdited, this, [this](const QString &text) {
const QString pattern = ".*" + QRegularExpression::escape(text) + ".*";
cardProxyModel->setFilterRegularExpression(
QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption));
if (!text.isEmpty()) {
searchCompleter->complete();
}
});
connectCardCompleterSearch(searchEdit, cardSetup);
connect(searchCompleter, static_cast<void (QCompleter::*)(const QString &)>(&QCompleter::activated), this,
[this](const QString &name) { searchEdit->setText(name); });
connect(searchEdit, &QLineEdit::editingFinished, this,

View file

@ -20,14 +20,23 @@
#include "../interface/card_picture_loader/card_picture_loader.h"
#include "../interface/widgets/cards/card_info_frame_widget.h"
#include "../interface/widgets/dialogs/dlg_create_game.h"
#include "../interface/widgets/dialogs/dlg_invite_to_game.h"
#include "../interface/widgets/server/game_link.h"
#include "../interface/widgets/server/user/user_list_manager.h"
#include "../interface/widgets/utility/completer_utils.h"
#include "../interface/widgets/utility/line_edit_completer.h"
#include "../interface/window_main.h"
#include "../main.h"
#include "../utility/visibility_change_listener.h"
#include "card/card_completer_proxy_model.h"
#include "card/card_search_model.h"
#include "card_database_display_model.h"
#include "card_database_model.h"
#include "tab_supervisor.h"
#include <QAction>
#include <QApplication>
#include <QClipboard>
#include <QCompleter>
#include <QDebug>
#include <QDockWidget>
@ -35,8 +44,12 @@
#include <QLabel>
#include <QMenu>
#include <QMessageBox>
#include <QPushButton>
#include <QRegularExpression>
#include <QStackedWidget>
#include <QStringListModel>
#include <QTimer>
#include <QVBoxLayout>
#include <QWidget>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h>
@ -286,6 +299,9 @@ void TabGame::retranslateUi()
QString tabText = " | " + type + " #" + QString::number(game->getGameMetaInfo()->gameId());
updatePlayerListDockTitle();
if (inviteButton) {
inviteButton->setText(tr("Invite"));
}
cardInfoDock->setWindowTitle(tr("Card Info") + (cardInfoDock->isWindow() ? tabText : QString()));
messageLayoutDock->setWindowTitle(tr("Messages") + (messageLayoutDock->isWindow() ? tabText : QString()));
if (replayDock) {
@ -324,6 +340,12 @@ void TabGame::retranslateUi()
if (aGameInfo) {
aGameInfo->setText(tr("Game &information"));
}
if (aCopyGameLink) {
aCopyGameLink->setText(tr("Cop&y game link"));
}
if (aInviteToGame) {
aInviteToGame->setText(tr("Invite to Game..."));
}
if (aConcede) {
if (game->getPlayerManager()->isMainPlayerConceded()) {
aConcede->setText(tr("Un&concede"));
@ -491,6 +513,56 @@ void TabGame::actGameInfo()
dlg.exec();
}
void TabGame::actCopyGameLink()
{
const QString link =
makeGameJoinLink(tabSupervisor->getClient()->serverName(), tabSupervisor->getClient()->serverPort(),
game->getGameMetaInfo()->proto().room_id(), game->getGameMetaInfo()->gameId(),
QString::fromStdString(game->getGameMetaInfo()->proto().description()));
QApplication::clipboard()->setText(link);
}
void TabGame::updateInviteButtonState()
{
// The dock button stays conservative (pre-start, not full); the menu action
// additionally covers started/full games, which are legitimate spectate
// invites, so it only needs the server-linked + not-closed conditions.
const bool canInvite = !tabSupervisor->getIsLocalGame() && !game->getGameState()->isGameClosed() &&
!game->getGameMetaInfo()->started() &&
game->getPlayerManager()->getPlayerCount() < game->getGameMetaInfo()->maxPlayers();
if (inviteButton) {
inviteButton->setVisible(canInvite);
}
if (aInviteToGame) {
aInviteToGame->setEnabled(!tabSupervisor->getIsLocalGame() && !game->getGameState()->isGameClosed());
}
}
void TabGame::actInviteToGame()
{
if (!tabSupervisor || tabSupervisor->getIsLocalGame()) {
return;
}
GameMetaInfo *metaInfo = game->getGameMetaInfo();
const QString inviteUrl = makeGameJoinLink(
tabSupervisor->getClient()->serverName(), tabSupervisor->getClient()->serverPort(), metaInfo->proto().room_id(),
metaInfo->gameId(), QString::fromStdString(metaInfo->proto().description()));
QStringList excludeUserNames;
excludeUserNames << tabSupervisor->getUserListManager()->getOwnUsername();
for (auto player : game->getPlayerManager()->getPlayers()) {
excludeUserNames << player->getPlayerInfo()->getName();
}
for (auto it = game->getPlayerManager()->getSpectators().cbegin();
it != game->getPlayerManager()->getSpectators().cend(); ++it) {
excludeUserNames << QString::fromStdString(it.value().name());
}
DlgInviteToGame dlg(tabSupervisor, inviteUrl, metaInfo->proto().only_buddies(), excludeUserNames, this);
dlg.exec();
}
void TabGame::actConcede()
{
PlayerLogic *player = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer());
@ -538,7 +610,8 @@ bool TabGame::leaveGame()
void TabGame::actSay()
{
if (completer->popup()->isVisible()) {
if (sayEdit->hasVisibleCompleterPopup()) {
sayEdit->hideCompleterPopups();
return;
}
@ -558,14 +631,14 @@ void TabGame::addPlayerToAutoCompleteList(QString playerName)
{
if (sayEdit && !autocompleteUserList.contains(playerName)) {
autocompleteUserList << playerName;
sayEdit->setCompletionList(autocompleteUserList);
mentionModel->setStringList(autocompleteUserList);
}
}
void TabGame::removePlayerFromAutoCompleteList(QString playerName)
{
if (sayEdit && autocompleteUserList.removeOne(playerName)) {
sayEdit->setCompletionList(autocompleteUserList);
mentionModel->setStringList(autocompleteUserList);
}
}
@ -628,8 +701,8 @@ void TabGame::actRotateViewCCW()
void TabGame::actCompleterChanged()
{
SettingsCache::instance().chat().getChatMentionCompleter() ? completer->setCompletionRole(2)
: completer->setCompletionRole(1);
SettingsCache::instance().chat().getChatMentionCompleter() ? mentionCompleter->setCompletionRole(2)
: mentionCompleter->setCompletionRole(1);
}
void TabGame::notifyPlayerJoin(QString playerName)
@ -838,6 +911,7 @@ void TabGame::stopGame()
QMapIterator<int, TabbedDeckViewContainer *> i(deckViewContainers);
while (i.hasNext()) {
i.next();
i.value()->playerDeckView->advancePlaymatRotation();
i.value()->show();
}
@ -978,6 +1052,11 @@ void TabGame::createMenuItems()
connect(aRotateViewCCW, &QAction::triggered, this, &TabGame::actRotateViewCCW);
aGameInfo = new QAction(this);
connect(aGameInfo, &QAction::triggered, this, &TabGame::actGameInfo);
aCopyGameLink = new QAction(this);
aCopyGameLink->setEnabled(!tabSupervisor->getIsLocalGame() && !tabSupervisor->getClient()->serverName().isEmpty());
connect(aCopyGameLink, &QAction::triggered, this, &TabGame::actCopyGameLink);
aInviteToGame = new QAction(this);
connect(aInviteToGame, &QAction::triggered, this, &TabGame::actInviteToGame);
aConcede = new QAction(this);
connect(aConcede, &QAction::triggered, this, &TabGame::actConcede);
if (!game->getGameMetaInfo()->started()) {
@ -1016,6 +1095,8 @@ void TabGame::createMenuItems()
gameMenu->addAction(aRotateViewCCW);
gameMenu->addSeparator();
gameMenu->addAction(aGameInfo);
gameMenu->addAction(aCopyGameLink);
gameMenu->addAction(aInviteToGame);
gameMenu->addAction(aConcede);
gameMenu->addAction(aFocusChat);
gameMenu->addAction(aLeaveGame);
@ -1024,6 +1105,9 @@ void TabGame::createMenuItems()
aCardMenu = gameMenu->addMenu(new QMenu(this));
// Sync the new action with the same state the dock button already shows.
updateInviteButtonState();
addTabMenu(gameMenu);
}
@ -1038,6 +1122,8 @@ void TabGame::createReplayMenuItems()
aRotateViewCCW = nullptr;
aResetLayout = nullptr;
aGameInfo = nullptr;
aCopyGameLink = nullptr;
aInviteToGame = nullptr;
aConcede = nullptr;
aFocusChat = nullptr;
aLeaveGame = new QAction(this);
@ -1236,11 +1322,29 @@ void TabGame::createPlayerListDock(bool bReplay)
}
playerListWidget->setFocusPolicy(Qt::NoFocus);
auto *playerListBox = new QWidget(this);
auto *vbox = new QVBoxLayout(playerListBox);
vbox->setContentsMargins(0, 0, 0, 0);
vbox->setSpacing(0);
vbox->addWidget(playerListWidget);
if (!bReplay) {
inviteButton = new QPushButton(tr("Invite"), playerListBox);
inviteButton->setVisible(false);
connect(inviteButton, &QPushButton::clicked, this, &TabGame::actInviteToGame);
vbox->addWidget(inviteButton);
connect(game->getGameMetaInfo(), &GameMetaInfo::startedChanged, this, &TabGame::updateInviteButtonState);
connect(game->getPlayerManager(), &PlayerManager::playerCountChanged, this, &TabGame::updateInviteButtonState);
updateInviteButtonState();
}
playerListDock = new QDockWidget(this);
playerListDock->setObjectName("playerListDock");
playerListDock->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetFloatable |
QDockWidget::DockWidgetMovable);
playerListDock->setWidget(playerListWidget);
playerListDock->setWidget(playerListBox);
playerListDock->setFloating(false);
}
@ -1264,6 +1368,7 @@ void TabGame::createMessageDock(bool bReplay)
qOverload<const QString &>(&CardInfoFrameWidget::setCard));
connect(messageLog, &MessageLogWidget::showCardInfoPopup, this, &TabGame::showCardInfoPopup);
connect(messageLog, &MessageLogWidget::deleteCardInfoPopup, this, &TabGame::deleteCardInfoPopup);
connect(messageLog, &MessageLogWidget::cockatriceLinkActivated, this, &TabGame::cockatriceLinkActivated);
if (!bReplay) {
connect(messageLog, &MessageLogWidget::openMessageDialog, this, &TabGame::openMessageDialog);
@ -1281,12 +1386,25 @@ void TabGame::createMessageDock(bool bReplay)
sayEdit->setMaxLength(MAX_TEXT_LENGTH);
sayLabel->setBuddy(sayEdit);
connect(this, &TabGame::chatMessageSent, game->getGameEventHandler(), &GameEventHandler::handleChatMessageSent);
completer = new QCompleter(autocompleteUserList, sayEdit);
completer->setCaseSensitivity(Qt::CaseInsensitive);
completer->setMaxVisibleItems(5);
completer->setFilterMode(Qt::MatchStartsWith);
mentionModel = new QStringListModel(autocompleteUserList, sayEdit);
mentionCompleter = createMentionCompleter(mentionModel, sayEdit);
sayEdit->addCompleter(mentionCompleter, CompleterTrigger::Mention);
auto *cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, sayEdit);
auto *displayModel = new CardDatabaseDisplayModel(sayEdit);
displayModel->setSourceModel(cardDatabaseModel);
const CardCompleterSetup cardSetup = createCardCompleter(displayModel, sayEdit);
sayEdit->addCompleter(cardSetup.completer, CompleterTrigger::Card);
connect(sayEdit, &LineEditCompleter::cardPartialChanged, this, [this, cardSetup](const QString &text) {
cardSetup.searchModel->updateSearchResults(text);
cardSetup.proxyModel->setFilterRegularExpression(
QRegularExpression(QRegularExpression::escape(text), QRegularExpression::CaseInsensitiveOption));
if (sayEdit->hasFocus()) {
cardSetup.completer->complete();
}
});
sayEdit->setCompleter(completer);
actCompleterChanged();
if (game->getPlayerManager()->isSpectator()) {

View file

@ -19,6 +19,7 @@
#include <QCompleter>
#include <QLoggingCategory>
#include <QMap>
#include <QStringListModel>
class CardMenu;
class ServerInfo_PlayerProperties;
@ -36,6 +37,7 @@ class CardInfoFrameWidget;
class QTimer;
class QSplitter;
class QLabel;
class QPushButton;
class QToolButton;
class QMenu;
class ZoneViewLayout;
@ -61,12 +63,14 @@ private:
const UserListProxy *userListProxy;
ReplayWidget *replayWidget = nullptr;
QStringList gameTypes;
QCompleter *completer;
QCompleter *mentionCompleter;
QStringListModel *mentionModel;
QStringList autocompleteUserList;
QStackedWidget *mainWidget;
CardInfoFrameWidget *cardInfoFrameWidget;
PlayerListWidget *playerListWidget;
QPushButton *inviteButton = nullptr;
QLabel *timeElapsedLabel;
MessageLogWidget *messageLog;
QLabel *sayLabel;
@ -81,9 +85,10 @@ private:
QAction *playersSeparator;
QMenu *gameMenu, *viewMenu;
TearOffMenu *phasesMenu;
QAction *aGameInfo, *aConcede, *aLeaveGame, *aNextPhase, *aNextPhaseAction, *aNextTurn, *aReverseTurn,
*aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, *aResetReplayLayout;
QAction *aGameInfo, *aConcede, *aCopyGameLink, *aLeaveGame, *aNextPhase, *aNextPhaseAction, *aNextTurn,
*aReverseTurn, *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, *aResetReplayLayout;
QAction *aFocusChat;
QAction *aInviteToGame = nullptr;
QList<QAction *> phaseActions;
QAction *aCardMenu;
@ -126,6 +131,7 @@ private:
void createPlayAreaWidget(bool bReplay = false);
void createDeckViewContainerWidget(bool bReplay = false);
void createReplayDock(GameReplay *replay);
void updateInviteButtonState();
signals:
void gameClosing(TabGame *tab);
void containerProcessingStarted(const GameEventContext &context);
@ -145,7 +151,9 @@ private slots:
void setCardMenu(CardMenu *menu);
void actGameInfo();
void actInviteToGame();
void actConcede();
void actCopyGameLink();
void actRemoveLocalArrows();
void actRotateViewCW();
void actRotateViewCCW();

View file

@ -23,14 +23,16 @@
TabMessage::TabMessage(TabSupervisor *_tabSupervisor,
AbstractClient *_client,
const ServerInfo_User &_ownUserInfo,
const ServerInfo_User &_otherUserInfo)
const ServerInfo_User &_otherUserInfo,
bool _userOnline)
: Tab(_tabSupervisor), client(_client), ownUserInfo(new ServerInfo_User(_ownUserInfo)),
otherUserInfo(new ServerInfo_User(_otherUserInfo)), userOnline(true)
otherUserInfo(new ServerInfo_User(_otherUserInfo)), userOnline(_userOnline)
{
chatView = new ChatView(tabSupervisor, 0, true);
connect(chatView, &ChatView::showCardInfoPopup, this, &TabMessage::showCardInfoPopup);
connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabMessage::deleteCardInfoPopup);
connect(chatView, &ChatView::addMentionTag, this, &TabMessage::addMentionTag);
connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabMessage::cockatriceLinkActivated);
sayEdit = new LineEditUnfocusable;
sayEdit->setMaxLength(MAX_TEXT_LENGTH);
connect(sayEdit, &LineEditUnfocusable::returnPressed, this, &TabMessage::sendMessage);
@ -94,28 +96,58 @@ void TabMessage::closeEvent(QCloseEvent *event)
event->accept();
}
void TabMessage::sendPrivateMessage(const QString &text)
{
Command_Message cmd;
cmd.set_user_name(otherUserInfo->name());
cmd.set_message(text.toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
pend->setExtraData(text);
connect(pend, &PendingCommand::finished, this, &TabMessage::messageSent);
client->sendCommand(pend);
}
void TabMessage::sendMessage()
{
if (sayEdit->text().isEmpty() || !userOnline) {
if (sayEdit->text().isEmpty()) {
return;
}
Command_Message cmd;
cmd.set_user_name(otherUserInfo->name());
cmd.set_message(sayEdit->text().toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabMessage::messageSent);
client->sendCommand(pend);
if (!userOnline) {
notifyUserOffline();
return;
}
sendPrivateMessage(sayEdit->text());
sayEdit->clear();
}
void TabMessage::messageSent(const Response &response)
bool TabMessage::isUserOnline() const
{
return userOnline;
}
void TabMessage::sendInviteMessage(const QString &text)
{
sayEdit->setText(text);
sendMessage();
}
void TabMessage::messageSent(const Response &response,
const CommandContainer & /*commandContainer*/,
const QVariant &extraData)
{
if (response.response_code() == Response::RespInIgnoreList) {
chatView->appendMessage(tr(
"This user is ignoring you, they cannot see your messages in main chat and you cannot join their games."));
} else if (response.response_code() == Response::RespNameNotFound) {
// The recipient went offline before the command reached the server: restore the draft.
userOnline = false;
if (sayEdit->text().isEmpty()) {
sayEdit->setText(extraData.toString());
}
notifyUserOffline();
}
}
@ -175,3 +207,8 @@ void TabMessage::processUserJoined(const ServerInfo_User &_userInfo)
userOnline = true;
*otherUserInfo = _userInfo;
}
void TabMessage::notifyUserOffline()
{
chatView->appendMessage(tr("Message not sent — %1 is offline.").arg(QString::fromStdString(otherUserInfo->name())));
}

View file

@ -19,6 +19,7 @@ class LineEditUnfocusable;
class Event_UserMessage;
class Response;
class ServerInfo_User;
class CommandContainer;
class TabMessage : public Tab
{
@ -39,7 +40,7 @@ signals:
void maximizeClient();
private slots:
void sendMessage();
void messageSent(const Response &response);
void messageSent(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData);
void addMentionTag(QString mentionTag);
void messageClicked();
@ -50,7 +51,8 @@ public:
TabMessage(TabSupervisor *_tabSupervisor,
AbstractClient *_client,
const ServerInfo_User &_ownUserInfo,
const ServerInfo_User &_otherUserInfo);
const ServerInfo_User &_otherUserInfo,
bool _userOnline);
~TabMessage() override;
void retranslateUi() override;
void tabActivated() override;
@ -62,9 +64,14 @@ public:
void processUserLeft();
void processUserJoined(const ServerInfo_User &_userInfo);
[[nodiscard]] bool isUserOnline() const;
void sendPrivateMessage(const QString &text);
void sendInviteMessage(const QString &text);
private:
bool shouldShowSystemPopup(const Event_UserMessage &event);
void showSystemPopup(const Event_UserMessage &event);
void notifyUserOffline();
};
#endif

View file

@ -0,0 +1,462 @@
#include "tab_moderation.h"
#include "abstract_client.h"
#include "tab_supervisor.h"
#include <QDateTime>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QSplitter>
#include <QTableWidget>
#include <QTextEdit>
#include <QVBoxLayout>
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
#include <libcockatrice/protocol/pb/response_moderator_last_logins.pb.h>
#include <libcockatrice/protocol/pb/response_remove_user_avatar.pb.h>
#include <libcockatrice/protocol/pb/response_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/response_reset_user_password.pb.h>
#include <libcockatrice/protocol/pb/response_user_alts.pb.h>
#include <libcockatrice/protocol/pb/response_user_sessions.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_user.pb.h>
#include <libcockatrice/protocol/pending_command.h>
namespace
{
constexpr int COL_ALTS_USER = 0;
constexpr int COL_ALTS_EMAIL = 1;
constexpr int COL_ALTS_CLIENTID = 2;
constexpr int COL_ALTS_REGISTERED = 3;
constexpr int COL_ALTS_LAST_LOGIN = 4;
constexpr int COL_ALTS_WARNS = 5;
constexpr int COL_ALTS_BANS = 6;
constexpr int COL_ALTS_ACTIVE = 7;
constexpr int COL_ALTS_COUNT = 8;
constexpr int COL_SESSIONS_IP = 0;
constexpr int COL_SESSIONS_CLIENTID = 1;
constexpr int COL_SESSIONS_START = 2;
constexpr int COL_SESSIONS_END = 3;
constexpr int COL_SESSIONS_TYPE = 4;
constexpr int COL_SESSIONS_COUNT = 5;
constexpr int COL_STAFF_USER = 0;
constexpr int COL_STAFF_LEVEL = 1;
constexpr int COL_STAFF_LAST_LOGIN = 2;
constexpr int COL_STAFF_COUNT = 3;
} // namespace
TabModeration::TabModeration(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, &TabModeration::investigateUser);
investigateButton = new QPushButton;
connect(investigateButton, &QPushButton::clicked, this, &TabModeration::investigateUser);
resetPasswordButton = new QPushButton;
connect(resetPasswordButton, &QPushButton::clicked, this, &TabModeration::resetPassword);
removeAvatarButton = new QPushButton;
connect(removeAvatarButton, &QPushButton::clicked, this, &TabModeration::removeAvatar);
auto *topBar = new QHBoxLayout;
topBar->addWidget(searchEdit);
topBar->addWidget(investigateButton);
topBar->addStretch();
topBar->addWidget(resetPasswordButton);
topBar->addWidget(removeAvatarButton);
userInfoGroup = new QGroupBox;
userInfoNameLabel = new QLabel;
userInfoNameValue = new QLabel;
userInfoRegisteredLabel = new QLabel;
userInfoRegisteredValue = new QLabel;
userInfoLastLoginLabel = new QLabel;
userInfoLastLoginValue = new QLabel;
userInfoStatusLabel = new QLabel;
userInfoStatusValue = new QLabel;
userInfoCountsLabel = new QLabel;
userInfoCountsValue = new QLabel;
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 *infoLayout = new QVBoxLayout(userInfoGroup);
infoLayout->addLayout(infoGrid);
auto configureTable = [](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);
};
altsGroup = new QGroupBox;
altsTable = new QTableWidget(0, COL_ALTS_COUNT);
configureTable(altsTable);
auto *altsLayout = new QVBoxLayout(altsGroup);
altsLayout->addWidget(altsTable);
sessionsGroup = new QGroupBox;
sessionsTable = new QTableWidget(0, COL_SESSIONS_COUNT);
configureTable(sessionsTable);
auto *sessionsLayout = new QVBoxLayout(sessionsGroup);
sessionsLayout->addWidget(sessionsTable);
staffGroup = new QGroupBox;
staffTable = new QTableWidget(0, COL_STAFF_COUNT);
configureTable(staffTable);
refreshStaffButton = new QPushButton;
connect(refreshStaffButton, &QPushButton::clicked, this, &TabModeration::requestModeratorLogins);
auto *staffHeader = new QHBoxLayout;
staffHeader->addStretch();
staffHeader->addWidget(refreshStaffButton);
auto *staffLayout = new QVBoxLayout(staffGroup);
staffLayout->addWidget(staffTable);
staffLayout->addLayout(staffHeader);
auto *splitter = new QSplitter(Qt::Vertical);
splitter->addWidget(userInfoGroup);
splitter->addWidget(altsGroup);
splitter->addWidget(sessionsGroup);
splitter->addWidget(staffGroup);
splitter->setStretchFactor(0, 1);
splitter->setStretchFactor(1, 2);
splitter->setStretchFactor(2, 2);
splitter->setStretchFactor(3, 1);
auto *mainLayout = new QVBoxLayout(centralWidget);
mainLayout->addLayout(topBar);
mainLayout->addWidget(splitter);
retranslateUi();
clearUserData();
requestModeratorLogins();
investigate(initialUser);
}
void TabModeration::retranslateUi()
{
searchEdit->setPlaceholderText(tr("User name"));
investigateButton->setText(tr("Investigate"));
resetPasswordButton->setText(tr("Reset Password"));
removeAvatarButton->setText(tr("Remove Avatar"));
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:"));
altsGroup->setTitle(tr("Alts"));
sessionsGroup->setTitle(tr("Sessions"));
staffGroup->setTitle(tr("Staff Last Logins"));
altsTable->setHorizontalHeaderLabels({tr("User"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"),
tr("Warns"), tr("Bans"), tr("Active")});
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");
}
void TabModeration::clearUserData()
{
currentUser.clear();
userInfoNameValue->clear();
userInfoRegisteredValue->clear();
userInfoLastLoginValue->clear();
userInfoStatusValue->clear();
userInfoCountsValue->clear();
userInfoNotesEdit->clear();
altsTable->setRowCount(0);
sessionsTable->setRowCount(0);
resetPasswordButton->setEnabled(false);
removeAvatarButton->setEnabled(false);
}
void TabModeration::investigate(const QString &userName)
{
if (userName.isEmpty()) {
return;
}
searchEdit->setText(userName);
investigateUser();
}
void TabModeration::investigateUser()
{
const QString userName = searchEdit->text().simplified();
if (userName.isEmpty()) {
return;
}
currentUser = userName;
resetPasswordButton->setEnabled(true);
removeAvatarButton->setEnabled(true);
altsTable->setRowCount(0);
sessionsTable->setRowCount(0);
requestUserInfo(userName);
requestSessions(userName);
requestAlts(userName);
}
void TabModeration::requestUserInfo(const QString &userName)
{
userInfoNameValue->setText(userName);
userInfoRegisteredValue->setText(tr("Loading..."));
userInfoLastLoginValue->setText(tr("Loading..."));
userInfoStatusValue->clear();
userInfoCountsValue->clear();
userInfoNotesEdit->clear();
Command_ReportUserInfo cmd;
cmd.set_user_name(userName.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::userInfoResponse);
client->sendCommand(pend);
}
void TabModeration::userInfoResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
userInfoRegisteredValue->clear();
userInfoLastLoginValue->clear();
userInfoStatusValue->setText(tr("Error loading user info."));
return;
}
const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext);
if (resp.has_user_name() && QString::fromStdString(resp.user_name()) != currentUser) {
return;
}
userInfoRegisteredValue->setText(formatEpoch(resp.registration_time()));
userInfoLastLoginValue->setText(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(", "));
userInfoCountsValue->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()) {
userInfoNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes()));
} else {
userInfoNotesEdit->setPlainText(tr("(none)"));
}
}
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);
client->sendCommand(pend);
}
void TabModeration::sessionsResponse(const Response &response)
{
sessionsTable->setRowCount(0);
if (response.response_code() != Response::RespOk) {
return;
}
const Response_UserSessions &resp = response.GetExtension(Response_UserSessions::ext);
sessionsTable->setRowCount(resp.sessions_size());
for (int i = 0; i < resp.sessions_size(); ++i) {
const ServerInfo_UserSession &session = resp.sessions(i);
sessionsTable->setItem(i, COL_SESSIONS_IP, new QTableWidgetItem(QString::fromStdString(session.ip_address())));
sessionsTable->setItem(i, COL_SESSIONS_CLIENTID,
new QTableWidgetItem(QString::fromStdString(session.clientid())));
sessionsTable->setItem(i, COL_SESSIONS_START, new QTableWidgetItem(formatEpoch(session.start_time())));
sessionsTable->setItem(
i, COL_SESSIONS_END,
new QTableWidgetItem(session.end_time() == 0 ? tr("Active") : formatEpoch(session.end_time())));
sessionsTable->setItem(i, COL_SESSIONS_TYPE,
new QTableWidgetItem(QString::fromStdString(session.connection_type())));
}
}
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);
client->sendCommand(pend);
}
void TabModeration::altsResponse(const Response &response)
{
altsTable->setRowCount(0);
if (response.response_code() != Response::RespOk) {
return;
}
const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext);
altsTable->setRowCount(resp.alts_size());
for (int i = 0; i < resp.alts_size(); ++i) {
const ServerInfo_UserAlt &alt = resp.alts(i);
altsTable->setItem(i, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name())));
altsTable->setItem(i, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email())));
altsTable->setItem(i, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid())));
altsTable->setItem(i, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time())));
altsTable->setItem(i, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login())));
altsTable->setItem(i, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count())));
altsTable->setItem(i, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count())));
altsTable->setItem(i, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no")));
}
}
void TabModeration::requestModeratorLogins()
{
Command_GetModeratorLastLogins cmd;
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::moderatorLoginsResponse);
client->sendCommand(pend);
}
void TabModeration::moderatorLoginsResponse(const Response &response)
{
staffTable->setRowCount(0);
if (response.response_code() != Response::RespOk) {
return;
}
const Response_ModeratorLastLogins &resp = response.GetExtension(Response_ModeratorLastLogins::ext);
staffTable->setRowCount(resp.logins_size());
for (int i = 0; i < resp.logins_size(); ++i) {
const ServerInfo_ModeratorLogin &login = resp.logins(i);
staffTable->setItem(i, COL_STAFF_USER, new QTableWidgetItem(QString::fromStdString(login.user_name())));
staffTable->setItem(i, COL_STAFF_LAST_LOGIN, new QTableWidgetItem(formatEpoch(login.last_login())));
QStringList levels;
if (login.user_level() & ServerInfo_User::IsAdmin) {
levels << tr("Admin");
}
if (login.user_level() & ServerInfo_User::IsModerator) {
levels << tr("Moderator");
}
if (login.user_level() & ServerInfo_User::IsJudge) {
levels << tr("Judge");
}
staffTable->setItem(i, COL_STAFF_LEVEL, new QTableWidgetItem(levels.join(" / ")));
}
}
void TabModeration::resetPassword()
{
if (currentUser.isEmpty()) {
return;
}
QMessageBox::StandardButton choice =
QMessageBox::warning(this, tr("Reset Password"),
tr("Reset the password of %1? A temporary password will be generated and shown to you. "
"The user must change it on their first login.")
.arg(currentUser),
QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel);
if (choice != QMessageBox::Ok) {
return;
}
Command_ResetUserPassword cmd;
cmd.set_user_name(currentUser.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::resetPasswordResponse);
client->sendCommand(pend);
}
void TabModeration::resetPasswordResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
QMessageBox::critical(this, tr("Error"), tr("Password reset failed."));
return;
}
const Response_ResetUserPassword &resp = response.GetExtension(Response_ResetUserPassword::ext);
QMessageBox::information(this, tr("Password reset"),
tr("Temporary password for %1:\n\n%2\n\nPass it to the user through a secure channel.")
.arg(QString::fromStdString(resp.user_name()))
.arg(QString::fromStdString(resp.temporary_password())));
}
void TabModeration::removeAvatar()
{
if (currentUser.isEmpty()) {
return;
}
QMessageBox::StandardButton choice =
QMessageBox::warning(this, tr("Remove Avatar"),
tr("Remove the avatar of %1? The user will have to upload a new one.").arg(currentUser),
QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel);
if (choice != QMessageBox::Ok) {
return;
}
Command_RemoveUserAvatar cmd;
cmd.set_user_name(currentUser.toStdString());
PendingCommand *pend = client->prepareModeratorCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabModeration::removeAvatarResponse);
client->sendCommand(pend);
}
void TabModeration::removeAvatarResponse(const Response &response)
{
if (response.response_code() != Response::RespOk) {
QMessageBox::critical(this, tr("Error"), tr("Could not remove the avatar."));
return;
}
const Response_RemoveUserAvatar &resp = response.GetExtension(Response_RemoveUserAvatar::ext);
QMessageBox::information(this, tr("Avatar removed"),
tr("The avatar of %1 has been removed.").arg(QString::fromStdString(resp.user_name())));
}

View file

@ -0,0 +1,83 @@
#ifndef TAB_MODERATION_H
#define TAB_MODERATION_H
#include "tab.h"
#include <libcockatrice/protocol/pb/response.pb.h>
class AbstractClient;
class QGroupBox;
class QLabel;
class QLineEdit;
class QPushButton;
class QTableWidget;
class QTextEdit;
/**
* Staff investigation tool. Lets moderators look up a user's account data,
* alternate accounts, login sessions, and staff login activity, and offers
* the password-reset and remove-avatar actions.
*/
class TabModeration : public Tab
{
Q_OBJECT
public:
explicit TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser = {});
void retranslateUi() override;
[[nodiscard]] QString getTabText() const override
{
return tr("Moderation");
}
void investigate(const QString &userName);
private slots:
void investigateUser();
void userInfoResponse(const Response &response);
void sessionsResponse(const Response &response);
void altsResponse(const Response &response);
void moderatorLoginsResponse(const Response &response);
void resetPassword();
void resetPasswordResponse(const Response &response);
void removeAvatar();
void removeAvatarResponse(const Response &response);
private:
void requestUserInfo(const QString &userName);
void requestSessions(const QString &userName);
void requestAlts(const QString &userName);
void requestModeratorLogins();
void clearUserData();
[[nodiscard]] QString formatEpoch(quint64 ts) const;
AbstractClient *client;
QString currentUser;
QLineEdit *searchEdit;
QPushButton *investigateButton;
QPushButton *resetPasswordButton;
QPushButton *removeAvatarButton;
QGroupBox *userInfoGroup;
QLabel *userInfoNameLabel;
QLabel *userInfoNameValue;
QLabel *userInfoRegisteredLabel;
QLabel *userInfoRegisteredValue;
QLabel *userInfoLastLoginLabel;
QLabel *userInfoLastLoginValue;
QLabel *userInfoStatusLabel;
QLabel *userInfoStatusValue;
QLabel *userInfoCountsLabel;
QLabel *userInfoCountsValue;
QLabel *userInfoNotesLabel;
QTextEdit *userInfoNotesEdit;
QGroupBox *altsGroup;
QTableWidget *altsTable;
QGroupBox *sessionsGroup;
QTableWidget *sessionsTable;
QGroupBox *staffGroup;
QTableWidget *staffTable;
QPushButton *refreshStaffButton;
};
#endif // TAB_MODERATION_H

Some files were not shown because too many files have changed in this diff Show more